JDK源码赏析(哈希容器)
HashMap
前置基础:核心成员变量
JDK1.7
- 底层结构:数组 + 链表,数组为哈希桶,链表解决哈希冲突
- 核心节点:
Entry<K,V>链表节点,包含key、value、hash、next(链表指针) - 核心变量+源码:
// 默认初始容量16(必须是2的幂,位运算优化) static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 最大容量2^30 static final int MAXIMUM_CAPACITY = 1 << 30; // 默认负载因子,平衡内存与冲突 static final float DEFAULT_LOAD_FACTOR = 0.75f; // 底层哈希桶数组,存储Entry节点 transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE; // 空数组常量,懒加载使用 static final Entry<?,?>[] EMPTY_TABLE = {}; // 实际存储的键值对数量 transient int size; // 扩容阈值 = 容量 * 负载因子,超过则扩容 int threshold; // 负载因子,构造后不可变 final float loadFactor; // 修改次数,用于fail-fast机制 transient int modCount;JDK1.8
- 底层结构:数组 + 链表 + 红黑树,解决长链表查询性能问题
- 核心节点:
Node<K,V>(基础链表节点,替代1.7 Entry)、TreeNode<K,V>(红黑树节点,继承Node) - 核心变量+源码(新增树化相关,优化懒加载):
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; static final int MAXIMUM_CAPACITY = 1 << 30; static final float DEFAULT_LOAD_FACTOR = 0.75f; // 链表转红黑树阈值(链表长度≥8触发) static final int TREEIFY_THRESHOLD = 8; // 红黑树转链表阈值(节点数≤6触发) static final int UNTREEIFY_THRESHOLD = 6; // 树化最小容量(数组容量≥64才树化,避免小容量频繁树化/退化) static final int MIN_TREEIFY_CAPACITY = 64; // 底层哈希桶数组,存储Node/TreeNode transient Node<K,V>[] table; static final Node<?,?>[] EMPTY_TABLE = {}; transient int size; int threshold; final float loadFactor; transient int modCount;一、初始化:4个构造方法
1. 无参构造:public HashMap()
核心作用
创建默认配置(容量16、负载因子0.75)的HashMap,懒加载延迟初始化数组
JDK1.7 源码+解析
public HashMap() { this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR); } // 调用带参构造,初始化负载因子和阈值 public HashMap(int initialCapacity, float loadFactor) { this.loadFactor = loadFactor; threshold = initialCapacity; init(); // 空方法,供子类重写 }- 直接计算阈值
threshold=16,但哈希桶table延迟至首次put时初始化
JDK1.8 源码+解析
public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // 仅初始化负载因子,无其他计算 }- 极致懒加载:仅赋值负载因子,
threshold和table均延迟至首次put初始化,减少空实例内存占用
阅读重点
JDK1.8 取消构造时的阈值计算,进一步优化懒加载逻辑
2. 指定初始容量:public HashMap(int initialCapacity)
核心作用
自定义初始容量,自动修正为最近的2的幂(保证哈希寻址效率)
JDK1.7/1.8 通用源码+解析
public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } // 核心:调用tableSizeFor修正容量 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; // JDK1.7:直接赋值阈值,1.8:延迟计算,此处仅临时存储容量 this.threshold = tableSizeFor(initialCapacity); } // 容量修正核心方法:向上取整为最近的2的幂 static final int tableSizeFor(int cap) { int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }阅读重点
tableSizeFor通过位运算快速修正容量(如输入10→16、输入17→32),保证容量为2的幂- 2的幂容量是为了后续通过
hash & (容量-1)高效计算桶索引,等价于取模但性能更高
3. 指定初始容量+负载因子:public HashMap(int initialCapacity, float loadFactor)
核心作用
自定义容量和负载因子,适配不同性能/内存需求(如内存紧张时提高负载因子)
核心源码
同上述HashMap(int initialCapacity, float loadFactor),仅直接传入自定义loadFactor
阅读重点
- 负载因子越大:内存利用率越高,哈希冲突概率越高;反之则冲突少、内存占用高
- 构造后负载因子不可变,需提前确定合适值
4. 传入Map构造:public HashMap(Map<? extends K, ? extends V> m)
核心作用
将现有Map转为HashMap,复用原有键值对
JDK1.7 源码+解析
public HashMap(Map<? extends K, ? extends V> m) { this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1, DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR); putAll(m); } // 逐个调用put插入,无容量预计算 public void putAll(Map<? extends K, ? extends V> m) { int numKeysToAdd = m.size(); if (numKeysToAdd == 0) return; if (table == EMPTY_TABLE) { inflateTable(threshold); // 初始化数组 } for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) { put(e.getKey(), e.getValue()); } }JDK1.8 源码+解析
public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); // 批量插入核心方法 } // 批量插入,预计算容量避免多次扩容 final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) { int s = m.size(); if (s > 0) { if (table == null) { // 数组未初始化 float ft = ((float)s / loadFactor) + 1.0F; int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY); if (t > threshold) threshold = tableSizeFor(t); } else if (s > threshold) { // 数组已初始化,需扩容 resize(); } // 批量插入键值对 for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) { K key = e.getKey(); V value = e.getValue(); putVal(hash(key), key, value, false, evict); } } }阅读重点
JDK1.8 新增putMapEntries,先根据传入Map的大小预计算容量,减少扩容次数,比1.7逐个put更高效
二、添加/修改:核心方法
1. 核心插入:public V put(K key, V value)
核心作用
添加键值对,若key已存在则覆盖旧值并返回旧值;不存在则新增,返回null
JDK1.7 源码+解析
public V put(K key, V value) { // 数组未初始化则先初始化 if (table == EMPTY_TABLE) { inflateTable(threshold); } // key为null时,单独处理,哈希桶索引固定为0 if (key == null) return putForNullKey(value); // 1. 计算key的哈希值(扰动处理,减少冲突) int hash = hash(key); // 2. 计算哈希桶索引 int i = indexFor(hash, table.length); // 3. 遍历链表,判断key是否存在 for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; // 覆盖旧值 e.recordAccess(this); return oldValue; } } modCount++; // 4. key不存在,头插法新增节点 addEntry(hash, key, value, i); return null; } // 哈希值扰动计算:4次位运算+1次异或,让高位参与运算 final int hash(Object k) { int h = hashSeed; if (0 != h && k instanceof String) { return sun.misc.Hashing.stringHash32((String) k); } h ^= k.hashCode(); h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); } // 计算桶索引:利用2的幂特性,高效寻址 static int indexFor(int h, int length) { return h & (length - 1); } // 头插法新增节点,插入链表头部 void addEntry(int hash, K key, V value, int bucketIndex) { if ((size >= threshold) && (null != table[bucketIndex])) { resize(2 * table.length); // 扩容为原2倍 hash = (null != key) ? hash(key) : 0; bucketIndex = indexFor(hash, table.length); } createEntry(hash, key, value, bucketIndex); } void createEntry(int hash, K key, V value, int bucketIndex) { Entry<K,V> e = table[bucketIndex]; // 头插法:新节点next指向原头节点,桶索引指向新节点 table[bucketIndex] = new Entry<>(hash, key, value, e); size++; }JDK1.8 源码+解析(核心简化,新增树化)
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } // 哈希值扰动优化:1次异或+1次无符号右移,简化但效果相当 static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } // 插入核心方法,整合扩容、树化、尾插法 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. 数组未初始化则先扩容(初始化) if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 2. 桶为空,直接新增节点插入 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; // 3. 头节点key匹配,直接覆盖 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; // 4. 头节点是红黑树,树插入 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); // 5. 遍历链表,尾插法新增 else { for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); // 链表长度≥8,触发树化判断 if (binCount >= TREEIFY_THRESHOLD - 1) treeifyBin(tab, hash); break; } // key存在,跳出循环覆盖 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } // key存在,覆盖旧值并返回 if (e != null) { V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } modCount++; // 6. 超过扩容阈值,触发扩容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; } // 树化方法:数组容量≥64才树化,否则仅扩容 final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) resize(); else if ((e = tab[index = (n - 1) & hash]) != null) { TreeNode<K,V> hd = null, tl = null; do { TreeNode<K,V> p = replacementTreeNode(e, null); if (tl == null) hd = p; else { p.prev = tl; tl.next = p; } tl = p; } while ((e = e.next) != null); if ((tab[index] = hd) != null) hd.treeify(tab); } }核心差异&阅读重点
- JDK1.8 用尾插法替代1.7头插法:避免多线程下链表成环问题,且保持链表插入顺序
- 哈希计算简化:1.8仅1次异或+右移,减少计算开销,同时让高位哈希参与运算
- 树化逻辑:链表长度≥8且数组容量≥64才树化,避免小容量时频繁树化/退化
- key为null:1.7单独处理,1.8整合到
hash(key)中,哈希值固定为0,桶索引0
2. 批量添加:public void putAll(Map<? extends K, ? extends V> m)
核心作用
批量插入Map中的键值对,比循环put更高效
核心源码
JDK1.7 直接遍历put,JDK1.8 调用上述putMapEntries(预计算容量+批量putVal)
阅读重点
开发中批量插入优先使用putAll,减少扩容次数和方法调用开销
三、容量管理:扩容核心方法 final Node<K,V>[] resize()
核心作用
HashMap动态扩容的核心,容量翻倍,重新哈希迁移所有节点,解决哈希冲突过多问题
JDK1.7 源码+解析
void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; } // 1. 新建2倍容量的新数组 Entry[] newTable = new Entry[newCapacity]; // 2. 头插法迁移节点,可能导致链表反转 transfer(newTable, initHashSeedAsNeeded(newCapacity)); table = newTable; // 3. 更新扩容阈值 threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); } // 节点迁移核心:头插法 void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry<K,V> e : table) { while(null != e) { Entry<K,V> next = e.next; if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity); // 头插法:新节点next指向新桶当前头节点 e.next = newTable[i]; newTable[i] = e; e = next; } } }JDK1.8 源码+解析(优化迁移,新增红黑树拆分)
final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; // 1. 计算新容量和新阈值 if (oldCap > 0) { // 原容量达最大值,不再扩容 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } // 容量翻倍,阈值也翻倍 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; } // 初始化时的阈值(如无参构造首次扩容) else if (oldThr > 0) newCap = oldThr; // 无参构造首次扩容,默认容量16,阈值12 else { newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } // 计算新阈值 if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY) ? (int)ft : Integer.MAX_VALUE; } threshold = newThr; // 2. 新建2倍容量的新数组 @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; // 3. 迁移原节点到新数组 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) newTab[e.hash & (newCap - 1)] = e; // 红黑树节点,拆分红黑树 else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); // 链表节点,尾插法迁移(无需重算哈希) else { Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { next = e.next; // 关键:通过hash & oldCap判断新索引,无需重算hash if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); // 低位节点:原索引插入 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } // 高位节点:原索引+oldCap插入 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }核心差异&阅读重点
- 扩容触发:1.7 插入前判断,1.8 插入后判断(
++size > threshold) - 节点迁移:
- 1.7 头插法:导致链表反转,多线程下易成环,且需重新计算哈希
- 1.8 尾插法:保持链表顺序,通过
hash & oldCap直接判断新索引(0→原索引,1→原索引+oldCap),无需重算哈希,效率大幅提升
- 红黑树处理:1.8 扩容时拆分红黑树为高低位子树,若子树节点数≤6则退化为链表
- 初始化整合:1.8 将数组初始化和扩容整合到
resize(),逻辑更统一
四、删除元素:核心方法
1. 核心删除:public V remove(Object key)
核心作用
根据key删除键值对,返回旧值;key不存在则返回null
JDK1.7 源码+解析
public V remove(Object key) { Entry<K,V> e = removeEntryForKey(key); return (e == null ? null : e.value); } final Entry<K,V> removeEntryForKey(Object key) { if (size == 0) return null; int hash = (key == null) ? 0 : hash(key); int i = indexFor(hash, table.length); Entry<K,V> prev = table[i]; Entry<K,V> e = prev; // 遍历链表,找到待删除节点 while (e != null) { Entry<K,V> next = e.next; Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { modCount++; size--; // 头节点删除:桶指向next if (prev == e) table[i] = next; // 中间节点删除:prev.next指向next else prev.next = next; e.recordRemoval(this); return e; } prev = e; e = next; } return e; }JDK1.8 源码+解析
public V remove(Object key) { Node<K,V> e; return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value; } // 删除核心方法,支持链表/红黑树删除 final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) { Node<K,V>[] tab; Node<K,V> p; int n, index; if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) { Node<K,V> node = null, e; K k; V v; // 头节点匹配 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) node = p; // 桶非空,遍历查找 else if ((e = p.next) != null) { // 红黑树节点,树查找 if (p instanceof TreeNode) node = ((TreeNode<K,V>)p).getTreeNode(hash, key); // 链表节点,遍历查找 else { do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { node = e; break; } p = e; } while ((e = e.next) != null); } } // 找到节点,执行删除 if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) { // 红黑树删除,删除后调整结构,节点数≤6则退化 if (node instanceof TreeNode) ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable); // 链表头节点删除 else if (node == p) tab[index] = node.next; // 链表中间节点删除 else p.next = node.next; modCount++; size--; afterNodeRemoval(node); return node; } } return null; }阅读重点
- JDK1.8 新增
removeNode,统一处理链表和红黑树删除,逻辑更简洁 - 红黑树删除后会自动调整树结构,若节点数≤6则退化为链表,保证性能
- 删除后仅调整节点指针,不缩容数组,哈希桶容量保持不变
2. 清空集合:public void clear()
核心源码+解析(JDK1.7/1.8 逻辑一致)
public void clear() { Node<K,V>[] tab; modCount++; if ((tab = table) != null && size > 0) { size = 0; // 遍历数组,置空所有桶,让GC回收节点 for (int i = 0; i < tab.length; ++i) tab[i] = null; } }阅读重点
- 仅置空哈希桶,不修改数组容量,实现数组复用
- 若需缩容,需手动调用
resize()指定小容量
五、查询元素:核心方法
1. 核心查询:public V get(Object key)
核心作用
根据key获取value,key不存在则返回null
JDK1.7 源码+解析
public V get(Object key) { if (key == null) return getForNullKey(); Entry<K,V> entry = getEntry(key); return null == entry ? null : entry.getValue(); } final Entry<K,V> getEntry(Object key) { int hash = (key == null) ? 0 : hash(key); // 哈希计算→桶定位→遍历链表匹配 for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } return null; } // key为null单独处理,桶索引0 private V getForNullKey() { if (size == 0) return null; for (Entry<K,V> e = table[0]; e != null; e = e.next) { if (e.key == null) return e.value; } return null; }JDK1.8 源码+解析
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; if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { // 头节点直接匹配,快速返回 if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k)))) return first; // 桶非空,继续查找 if ((e = first.next) != null) { // 红黑树查询,时间复杂度O(logn) if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); // 链表查询,时间复杂度O(n) do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; }核心差异&阅读重点
- JDK1.8 新增
getNode,统一处理链表/红黑树查询,头节点匹配直接返回,优化查询效率 - 红黑树查询:将长链表的O(n)查询复杂度降为O(logn),解决1.7长链表查询慢的问题
- key为null:1.8整合到
hash(key),无需单独处理,逻辑更统一
2. 包含判断:public boolean containsKey(Object key)
核心源码
JDK1.7 调用getEntry(key) != null,JDK1.8 调用getNode(hash(key), key) != null
阅读重点
与get逻辑一致,仅返回布尔值,无额外性能开销
六、JDK1.7/1.8 核心差异总表
对比维度 | JDK1.7 | JDK1.8 |
底层结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
链表插入方式 | 头插法(多线程成环风险) | 尾插法(避免成环,保持顺序) |
扩容节点迁移 | 头插法(链表反转,重算哈希) | 尾插法(保持顺序,无需重算哈希) |
哈希计算 | 4次扰动+1次异或(复杂) | 1次异或+1次无符号右移(简化) |
桶索引计算 | 封装 | 内联 |
树化机制 | 无 | 链表≥8且容量≥64时树化 |
退化机制 | 无 | 红黑树≤6时退化为链表 |
初始化逻辑 | 构造时计算阈值,数组懒加载 | 极致懒加载,首次put才初始化数组+阈值 |
批量插入 | 遍历逐个put,无容量预计算 |
|
查询效率 | 链表O(n),长链表性能差 | 红黑树O(logn),长链表性能大幅提升 |
核心统一方法 | 增删查各有独立逻辑 | 新增 |
LinkedHashMap
前置基础:核心特性&设计定位
核心定位
java.util.LinkedHashMap 是HashMap的子类,JDK1.4引入,在HashMap的数组+链表/红黑树底层基础上,额外维护了一条双向链表,用于记录键值对的插入顺序/访问顺序,实现了有序的哈希表;非线程安全,继承HashMap的所有特性(支持null键值、哈希扰动、红黑树优化等),同时解决了HashMap遍历无序的问题。
它是有序Map的首选实现(比TreeMap效率更高,无需排序),适用于需要保留插入顺序或实现LRU(最近最少使用)缓存的场景,平均增删查时间复杂度仍为O(1),仅在有序维护上增加少量开销。
核心特性
- 继承HashMap:完全复用HashMap的底层结构(数组+链表/红黑树)、哈希计算、扩容机制、null支持等,仅新增有序相关逻辑;
- 双向链表维护有序:通过**头节点(header)和尾节点(tail)**维护一条双向链表,所有键值对节点都加入该链表,记录顺序;
- 两种有序模式:
- 插入顺序(默认):遍历顺序与键值对的插入顺序一致,重复put已存在的键,顺序不改变;
- 访问顺序:调用
get/put/containsKey等方法访问节点后,该节点会被移到双向链表尾部,遍历顺序为访问时间从早到晚(可实现LRU缓存);
- 非线程安全:无同步锁,多线程并发修改会抛出
ConcurrentModificationException,高并发需手动加锁; - 支持null键值:与HashMap一致,键允许1个null,值允许多个null;
- 迭代效率更高:遍历基于双向链表,无需遍历整个哈希表(跳过空桶),比HashMap的无序遍历效率更高;
- 快速失败机制:继承HashMap的
modCount,遍历过程中修改集合会触发快速失败; - 扩容无额外成本:扩容时仅复用HashMap的扩容逻辑,双向链表的顺序在节点复制时自动保留,无需额外处理。
与核心Map的对比(开发/面试必问)
1. LinkedHashMap vs HashMap(核心继承关系,有序vs无序)
两者本质是子类与父类,LinkedHashMap仅在HashMap基础上增加有序特性,核心区别是是否有序、遍历方式、底层节点:
特性 | LinkedHashMap | HashMap |
有序性 | 有序(插入/访问顺序) | 无序(由哈希值决定) |
底层结构 | 数组+链表/红黑树 + 双向链表 | 数组+链表/红黑树 |
节点类型 | Entry(继承HashMap.Node,新增before/after指针) | Node/TreeNode |
遍历方式 | 基于双向链表(高效,无空桶) | 基于哈希表(需遍历空桶) |
重复put已存在键 | 不改变有序性 | 无有序性概念 |
访问节点后行为 | 访问顺序模式下移到尾部 | 无特殊行为 |
时间复杂度 | 平均O(1)(略高于HashMap,维护双向链表) | 平均O(1) |
内存占用 | 稍高(节点多2个指针) | 稍低 |
适用场景 | 需保留顺序、LRU缓存 | 纯键值对存储,追求极致性能 |
核心优势 | 有序且哈希表效率 | 极致的单线程性能 |
2. LinkedHashMap vs TreeMap(有序Map的两大实现)
两者均为有序Map,但有序实现方式、底层结构完全不同,LinkedHashMap是绝大多数有序场景的首选:
特性 | LinkedHashMap | TreeMap |
有序实现 | 双向链表(插入/访问顺序,无需排序) | 红黑树(键的自然顺序/自定义Comparator) |
时间复杂度 | 平均O(1)(增删查) | O(logn)(增删查,红黑树操作) |
键要求 | 无要求(无需实现Comparable) | 需实现Comparable或自定义Comparator |
null键支持 | 允许1个null键 | 不支持null键(会抛ClassCastException) |
底层结构 | 哈希表+双向链表 | 纯红黑树 |
遍历效率 | 高(双向链表直接遍历) | 中(红黑树中序遍历) |
适用场景 | 保留插入/访问顺序、LRU缓存 | 需按键排序(升序/降序)的场景 |
性能开销 | 低(仅维护双向链表) | 高(红黑树旋转平衡) |
3. LinkedHashMap vs ConcurrentLinkedHashMap
两者均支持LRU缓存,但核心区别是线程安全、是否JDK原生:
特性 | LinkedHashMap | ConcurrentLinkedHashMap |
线程安全 | 否(JDK原生) | 是(第三方扩展,Guava提供) |
LRU实现 | 重写 | 原生支持LRU,无需自定义 |
底层结构 | 哈希表+双向链表 | 哈希表+双向链表 |
null支持 | 支持 | 不支持 |
适用场景 | 单线程LRU缓存 | 高并发LRU缓存 |
一、底层核心原理:HashMap+双向链表
LinkedHashMap的核心是继承HashMap并增强节点结构,通过在节点中增加before/after指针维护双向链表,既保留了HashMap的O(1)哈希表效率,又实现了有序遍历,是组合设计的经典案例。
1. 核心底层属性
public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V> { // 双向链表的头节点(最早插入/最早访问的节点) transient LinkedHashMap.Entry<K,V> head; // 双向链表的尾节点(最新插入/最新访问的节点) transient LinkedHashMap.Entry<K,V> tail; // 有序模式标识:true=访问顺序,false=插入顺序(默认) final boolean accessOrder; // 序列化版本号 private static final long serialVersionUID = 3801124242820219131L; }核心常量:accessOrder为final,构造时指定且不可修改,决定LinkedHashMap的有序模式,默认false(插入顺序)。
2. 节点定义:继承HashMap.Node并新增双向链表指针
LinkedHashMap的Entry节点是HashMap.Node的子类,仅新增before(前驱)和after(后继)指针,用于构建双向链表,不改变HashMap的节点哈希特性:
static class Entry<K,V> extends HashMap.Node<K,V> { Entry<K,V> before, after; // 双向链表的前驱、后继指针 Entry(int hash, K key, V value, Node<K,V> next) { super(hash, key, value, next); // 调用HashMap.Node的构造方法 } }节点双重身份:
- 作为哈希表节点:
next指针用于解决哈希冲突,构成数组后的单向链表/红黑树; - 作为双向链表节点:
before/after指针用于维护有序性,所有节点串联成双向链表。
3. 底层结构示意图(插入顺序,容量16)
// 哈希表结构(数组+链表,与HashMap一致) 数组索引:0 1 2 ... 8 ... 15 ↓ ↓ ↓ ↓ ↓ 桶结构: - A - ... B→C ... - (B、C哈希冲突,next指针串联) // 双向链表结构(维护插入顺序:A→B→C,before/after指针串联) head → A (before:null, after:B) → B (before:A, after:C) → C (before:B, after:null) → tail- 哈希表负责O(1)增删查,双向链表负责有序遍历;
- 所有节点同时属于哈希表和双向链表,两种结构互不干扰,仅在节点增删时同步维护。
4. 有序模式核心逻辑
(1)插入顺序(默认,accessOrder=false)
- 新节点始终插入到双向链表的尾部;
- 重复put已存在的键(覆盖值),节点在双向链表中的位置不变;
- 遍历顺序与首次插入顺序完全一致,符合“先进先出”。
(2)访问顺序(accessOrder=true)
- 新节点仍插入到双向链表尾部;
- 调用
get/put(已存在键)/containsKey等方法访问节点后,该节点会被移除并重新插入到双向链表尾部(成为“最新访问节点”); - 遍历顺序为访问时间从早到晚,最尾部是最新访问的节点,最头部是最久未访问的节点(LRU缓存的核心原理)。
二、核心构造方法
LinkedHashMap提供4个构造方法,均调用HashMap的构造方法完成哈希表初始化,再指定accessOrder(默认false),支持默认初始化、指定容量/加载因子、传入Map、指定容量+加载因子+有序模式:
1. 空参构造:new LinkedHashMap<>()
public LinkedHashMap() { // 调用HashMap空参构造(默认容量16,加载因子0.75) super(); // 默认插入顺序 accessOrder = false; }- 最常用构造方法,默认插入顺序,满足绝大多数有序存储场景;
- 哈希表懒加载,首次put时初始化数组。
2. 指定初始容量/加载因子:new LinkedHashMap(int initialCapacity)/new LinkedHashMap(int initialCapacity, float loadFactor)
public LinkedHashMap(int initialCapacity) { super(initialCapacity); accessOrder = false; } public LinkedHashMap(int initialCapacity, float loadFactor) { super(initialCapacity, loadFactor); accessOrder = false; }- 适合已知键值对大致数量的场景,避免频繁扩容;
- 加载因子建议使用默认0.75(时间/空间平衡值)。
3. 传入Map初始化:new LinkedHashMap(Map<? extends K, ? extends V> m)
public LinkedHashMap(Map<? extends K, ? extends V> m) { super(); accessOrder = false; // 调用putAll批量添加,双向链表按传入Map的迭代顺序构建 putAll(m); }- 将传入的Map(如HashMap)键值对批量复制,双向链表按传入Map的迭代顺序排列;
- 若传入的Map是LinkedHashMap,会保留其原有顺序;若为HashMap,顺序为其无序的哈希遍历顺序。
4. 核心构造:new LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder)
public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) { super(initialCapacity, loadFactor); // 手动指定有序模式:true=访问顺序,false=插入顺序 this.accessOrder = accessOrder; }- 唯一能指定访问顺序的构造方法,是实现LRU缓存的关键;
- 示例:
new LinkedHashMap<>(16, 0.75f, true)构建一个访问顺序的LinkedHashMap。
关键注意点
- 继承HashMap的所有特性:支持null键(1个)、null值(多个),扩容机制与HashMap一致(原容量×2,2的幂),红黑树优化(链表≥8转红黑树);
- accessOrder不可修改:一旦构造时指定,运行时无法改变有序模式;
- 初始容量计算:与HashMap一致,建议设置为
(预计元素数 / 0.75) + 1,减少扩容次数。
三、核心底层逻辑:双向链表的维护(新增/访问/删除)
LinkedHashMap的有序性完全依赖双向链表的增删改,它通过重写HashMap的钩子方法实现双向链表的自动维护,无需修改HashMap的核心逻辑,这是其设计的精妙之处。
HashMap为子类预留了3个核心钩子方法,LinkedHashMap通过重写这些方法,在哈希表节点增删时同步维护双向链表:
// HashMap中的钩子方法,默认空实现,供子类重写 // 1. 新节点插入哈希表后调用 void afterNodeInsertion(boolean evict) {} // 2. 节点被访问后调用(get/put已存在键) void afterNodeAccess(Node<K,V> e) {} // 3. 节点被删除后调用 void afterNodeRemoval(Node<K,V> e) {}1. 新增节点:维护双向链表(插入尾部)
当调用put添加新节点(键不存在)时,HashMap会先将节点插入哈希表(数组/链表/红黑树),然后调用afterNodeInsertion,LinkedHashMap重写该方法的核心逻辑是将新节点插入双向链表尾部:
// LinkedHashMap重写:将节点链接到双向链表尾部 private void linkNodeLast(LinkedHashMap.Entry<K,V> p) { LinkedHashMap.Entry<K,V> last = tail; tail = p; if (last == null) // 双向链表为空,新节点作为头节点 head = p; else { // 双向链表非空,新节点接在尾部,更新前驱后继 p.before = last; last.after = p; } } // 重写HashMap的newNode,创建LinkedHashMap.Entry节点并链接到尾部 Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) { LinkedHashMap.Entry<K,V> p = new LinkedHashMap.Entry<>(hash, key, value, e); linkNodeLast(p); return p; } // 红黑树节点的创建,同样链接到双向链表尾部 TreeNode<K,V> newTreeNode(int hash, K key, V value, Node<K,V> next) { TreeNode<K,V> p = new TreeNode<>(hash, key, value, next); linkNodeLast((LinkedHashMap.Entry<K,V>)p); return p; }核心流程:创建LinkedHashMap.Entry节点 → 插入哈希表 → 调用linkNodeLast插入双向链表尾部,哈希表和双向链表同步更新。
2. 访问节点:访问顺序模式下移到尾部(LRU核心)
当调用get/put(已存在键)访问节点时,HashMap会找到该节点,然后调用afterNodeAccess,LinkedHashMap重写该方法,在访问顺序模式下将节点移到双向链表尾部:
// 重写HashMap的afterNodeAccess:访问节点后处理 void afterNodeAccess(Node<K,V> e) { // move node to last LinkedHashMap.Entry<K,V> last; // 仅在访问顺序模式,且节点不是尾节点时执行 if (accessOrder && (last = tail) != e) { LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after; p.after = null; // 断开当前节点的后继 if (b == null) // 当前节点是头节点,更新头节点为后继 head = a; else // 否则,更新前驱的后继为当前节点的后继 b.after = a; if (a != null) // 更新后继的前驱为当前节点的前驱 a.before = b; else // 若后继为空,更新last为前驱(防止空指针) last = b; if (last == null) // 双向链表为空,当前节点作为头节点 head = p; else { // 否则,将当前节点插入尾部 p.before = last; last.after = p; } tail = p; // 更新尾节点为当前节点 modCount++; // 修改次数+1,触发快速失败 } }核心效果:访问后的节点成为双向链表最新尾部,保证访问顺序模式下“最新访问的节点在尾部,最久未访问的在头部”。
3. 删除节点:同步移除双向链表中的节点
当调用remove删除节点时,HashMap会先从哈希表中删除节点,然后调用afterNodeRemoval,LinkedHashMap重写该方法,将节点从双向链表中同步移除,保证双向链表结构完整:
// 重写HashMap的afterNodeRemoval:删除节点后处理 void afterNodeRemoval(Node<K,V> e) { // unlink LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after; // 清空当前节点的前驱后继,便于GC回收 p.before = p.after = null; if (b == null) // 当前节点是头节点,更新头节点为后继 head = a; else // 否则,更新前驱的后继为后继 b.after = a; if (a == null) // 当前节点是尾节点,更新尾节点为前驱 tail = b; else // 否则,更新后继的前驱为前驱 a.before = b; }核心保证:哈希表和双向链表的节点数量始终一致,删除操作不会破坏有序性。
4. 扩容时的有序性保留
LinkedHashMap的扩容完全复用HashMap的扩容逻辑,无需额外处理双向链表:
- 扩容时,HashMap会将原哈希表的节点复制到新数组,LinkedHashMap的节点在复制时,
before/after指针保持不变; - 新哈希表的双向链表与原链表的顺序完全一致,有序性不会因扩容丢失。
四、核心方法:继承+增强(有序性相关)
LinkedHashMap继承HashMap的所有核心方法(put/get/remove/size等),仅重写了get、containsValue和遍历相关方法,新增了有序性的支持,核心方法的时间复杂度仍为O(1)(访问顺序模式的get因移节点会稍慢,但仍为O(1))。
1. 查询方法:get(Object key)(重写,支持访问顺序)
public V get(Object key) { Node<K,V> e; // 调用HashMap的getNode方法查找节点(O(1)) if ((e = getNode(hash(key), key)) == null) return null; // 访问顺序模式,调用afterNodeAccess将节点移到尾部 if (accessOrder) afterNodeAccess(e); return e.value; }核心区别:比HashMap的get多了访问顺序的处理,这是实现LRU的关键。
2. 包含值判断:containsValue(Object value)(重写,效率更高)
public boolean containsValue(Object value) { // 基于双向链表遍历,无需遍历哈希表的空桶,效率远高于HashMap for (LinkedHashMap.Entry<K,V> e = head; e != null; e = e.after) { V v = e.value; if (v == value || (value != null && value.equals(v))) return true; } return false; }效率提升:HashMap的containsValue需要遍历整个哈希表(包括空桶),而LinkedHashMap基于双向链表遍历,直接跳过空桶,遍历节点数等于实际元素数,效率更高。
3. 有序遍历(核心优势,基于双向链表)
LinkedHashMap重写了HashMap的遍历相关方法,所有遍历(entrySet/keySet/values)均基于双向链表实现,遍历顺序与有序模式一致,且效率更高:
(1)插入顺序模式(默认):遍历与插入顺序一致
LinkedHashMap<String, String> lhm = new LinkedHashMap<>(); lhm.put("a", "1"); lhm.put("b", "2"); lhm.put("c", "3"); lhm.put("a", "4"); // 重复put,顺序不变 // 遍历顺序:a→b→c(与首次插入顺序一致) for (Map.Entry<String, String> entry : lhm.entrySet()) { System.out.println(entry.getKey() + "=" + entry.getValue()); // a=4, b=2, c=3 }(2)访问顺序模式:遍历与访问时间一致(LRU)
// 构建访问顺序的LinkedHashMap LinkedHashMap<String, String> lhm = new LinkedHashMap<>(16, 0.75f, true); lhm.put("a", "1"); lhm.put("b", "2"); lhm.put("c", "3"); // 双向链表:a→b→c(head→tail) lhm.get("a"); // 访问a,a移到尾部 → b→c→a lhm.put("b", "5"); // 访问b,b移到尾部 → c→a→b lhm.containsKey("c"); // 访问c,c移到尾部 → a→b→c // 遍历顺序:a(最久未访问)→b→c(最新访问) for (Map.Entry<String, String> entry : lhm.entrySet()) { System.out.println(entry.getKey()); // a, b, c }4. LRU缓存实现:重写removeEldestEntry(Map.Entry<K,V> eldest)
LinkedHashMap内置了LRU缓存的核心能力,只需重写removeEldestEntry方法,指定缓存最大容量,当元素数超过容量时,会自动删除最久未访问的节点(双向链表头节点),实现LRU(最近最少使用)缓存。
(1)核心原理
removeEldestEntry是LinkedHashMap的保护方法,默认返回false(不删除旧节点);- 每次
put添加新节点后,HashMap会调用afterNodeInsertion,该方法会判断removeEldestEntry(eldest)的返回值:- 若返回
true,则自动删除双向链表的头节点(最久未访问节点); - 若返回
false,则不删除。
- 若返回
(2)自定义LRU缓存(固定容量,线程不安全)
// 自定义LRU缓存,继承LinkedHashMap,重写removeEldestEntry public class LruCache<K, V> extends LinkedHashMap<K, V> { private final int MAX_CAPACITY; // 缓存最大容量 // 构造方法:指定容量,访问顺序模式 public LruCache(int maxCapacity) { super(maxCapacity, 0.75f, true); this.MAX_CAPACITY = maxCapacity; } // 重写:当元素数超过最大容量时,删除最久未访问的节点 @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { // 关键:size() > 最大容量时返回true,触发自动删除 return size() > MAX_CAPACITY; } // 测试 public static void main(String[] args) { LruCache<String, String> cache = new LruCache<>(3); // 最大容量3 cache.put("a", "1"); cache.put("b", "2"); cache.put("c", "3"); // 容量3,正常存储:a→b→c System.out.println(cache); // {a=1, b=2, c=3} cache.put("d", "4"); // 容量超3,删除最久未访问的a → b→c→d System.out.println(cache); // {b=2, c=3, d=4} cache.get("b"); // 访问b,b移到尾部 → c→d→b cache.put("e", "5"); // 容量超3,删除最久未访问的c → d→b→e System.out.println(cache); // {d=4, b=2, e=5} } }核心效果:缓存容量固定,自动淘汰最久未使用的节点,完美实现LRU缓存,代码极简。
(3)线程安全的LRU缓存
上述自定义LruCache是非线程安全的,若需高并发使用,有两种方案:
- 简单方案:通过
Collections.synchronizedMap包装,低并发可用:
Map<String, String> syncLruCache = Collections.synchronizedMap(new LruCache<>(3));- 推荐方案:使用Guava的
com.google.common.cache.CacheBuilder构建LRU缓存(高并发、功能丰富):
// Guava的LRU缓存,线程安全,支持过期时间等 Cache<String, String> guavaCache = CacheBuilder.newBuilder() .maximumSize(3) // 最大容量3 .build(); guavaCache.put("a", "1"); guavaCache.getIfPresent("a"); // 获取值五、线程安全处理
LinkedHashMap非线程安全,与HashMap一致,多线程并发修改(如同时put/remove/get)会导致:
- 双向链表结构错乱,遍历得到错误顺序;
- 哈希表节点覆盖,数据丢失;
- 触发快速失败,抛出
ConcurrentModificationException。
线程安全处理方案(3种,优先级从高到低)
1. 低并发场景:Collections.synchronizedMap包装
// 包装为线程安全的LinkedHashMap,所有方法加synchronized全局锁 Map<String, String> syncLhm = Collections.synchronizedMap(new LinkedHashMap<>()); // 遍历需手动加锁,避免快速失败 synchronized (syncLhm) { for (Map.Entry<String, String> entry : syncLhm.entrySet()) { System.out.println(entry); } }- 实现简单,适合低并发;
- 并发效率低,全局锁导致所有操作串行。
2. 高并发场景:手动加锁(ReentrantLock)
LinkedHashMap<String, String> lhm = new LinkedHashMap<>(); ReentrantLock lock = new ReentrantLock(); // 加锁put lock.lock(); try { lhm.put("a", "1"); } finally { lock.unlock(); } // 加锁get lock.lock(); try { lhm.get("a"); } finally { lock.unlock(); }- 比synchronized更灵活(支持公平锁、可中断锁);
- 适合需要自定义锁策略的场景。
3. 高并发LRU缓存:Guava ConcurrentLinkedHashMap/ Caffeine
- JDK原生无线程安全的LinkedHashMap,高并发LRU缓存推荐使用Guava的ConcurrentLinkedHashMap或Caffeine(性能更高);
- 原生支持LRU、线程安全、高并发,无需自定义继承。
六、开发/面试高频考点
1. LinkedHashMap的底层实现是什么?核心原理是什么?(核心考点)
- 底层是HashMap的子类,复用HashMap的数组+链表/红黑树哈希表结构,额外维护一条双向链表;
- 核心原理:通过继承HashMap并重写其钩子方法(afterNodeInsertion/afterNodeAccess/afterNodeRemoval),在哈希表节点增删查时,同步维护双向链表的有序性,既保留HashMap的O(1)效率,又实现有序遍历。
2. LinkedHashMap有哪两种有序模式?分别适用于什么场景?
- 插入顺序(默认,accessOrder=false):遍历与首次插入顺序一致,重复put不改变顺序,适用于需要保留插入顺序的场景(如配置解析、日志记录);
- 访问顺序(accessOrder=true):访问节点后节点移到双向链表尾部,遍历顺序为访问时间从早到晚,适用于实现LRU(最近最少使用)缓存的场景。
3. LinkedHashMap如何实现LRU缓存?核心方法是什么?
- 核心依托访问顺序模式(accessOrder=true)和重写removeEldestEntry方法;
- 步骤:1. 继承LinkedHashMap,构造时指定accessOrder=true;2. 重写removeEldestEntry,当size()超过缓存最大容量时返回true;3. 每次添加新节点后,LinkedHashMap会自动删除双向链表头节点(最久未访问节点);
- 核心方法:
removeEldestEntry(Map.Entry<K,V> eldest),默认返回false,重写后实现缓存淘汰。
4. LinkedHashMap和HashMap的核心区别?为什么LinkedHashMap是有序的?
核心区别
- 有序性:LinkedHashMap有序(插入/访问),HashMap无序;
- 底层结构:LinkedHashMap多了双向链表,节点多before/after指针;
- 遍历方式:LinkedHashMap基于双向链表(高效,无空桶),HashMap基于哈希表(需遍历空桶);
- 内存占用:LinkedHashMap稍高(节点多2个指针);
- 方法重写:LinkedHashMap重写了get/containsValue/遍历方法,HashMap无。
有序的原因
LinkedHashMap通过双向链表记录所有节点的顺序,哈希表负责高效增删查,双向链表负责有序遍历,两种结构独立维护且同步更新,因此实现了有序。
5. LinkedHashMap和TreeMap的区别?如何选择有序Map?
核心区别
- 有序实现:LinkedHashMap是双向链表的插入/访问顺序(无需排序),TreeMap是红黑树的键排序(自然顺序/自定义Comparator);
- 时间复杂度:LinkedHashMap增删查平均O(1),TreeMap O(logn);
- 键要求:LinkedHashMap无要求,TreeMap的键需实现Comparable或自定义Comparator;
- null键:LinkedHashMap允许1个null键,TreeMap不支持;
- 性能开销:LinkedHashMap仅维护双向链表,开销低;TreeMap需红黑树旋转平衡,开销高。
选择原则
- 选LinkedHashMap:需要保留插入/访问顺序,无需按键排序,追求哈希表的O(1)效率(绝大多数有序场景);
- 选TreeMap:需要按键的大小排序(升序/降序),如排行榜、数据统计等场景。
6. LinkedHashMap的节点有什么特殊之处?
LinkedHashMap的Entry节点继承自HashMap.Node,仅新增了before(前驱)和after(后继)指针,使节点同时具备两种身份:
- 作为哈希表节点:通过next指针解决哈希冲突,构成链表/红黑树;
- 作为双向链表节点:通过before/after指针维护有序性,所有节点串联成双向链表。
7. LinkedHashMap的扩容会破坏有序性吗?为什么?
- 不会;
- 原因:LinkedHashMap的扩容完全复用HashMap的逻辑,扩容时仅将原哈希表的节点复制到新数组,节点的before/after指针在复制过程中保持不变,因此双向链表的顺序与原链表完全一致,有序性不会丢失。
8. LinkedHashMap的containsValue方法为什么比HashMap高效?
- HashMap的containsValue需要遍历整个哈希表(包括所有桶,即使桶为空),遍历的节点数可能远大于实际元素数;
- LinkedHashMap的containsValue基于双向链表遍历,直接跳过哈希表的空桶,遍历的节点数等于实际元素数,因此效率更高。
9. LinkedHashMap是线程安全的吗?如何实现线程安全?
- 非线程安全,与HashMap一致,无同步锁;
- 线程安全实现方案:1. 低并发用
Collections.synchronizedMap包装;2. 中等并发用ReentrantLock手动加锁;3. 高并发LRU缓存用Guava ConcurrentLinkedHashMap/Caffeine。
10. LinkedHashMap中,重复put已存在的键,会改变其在双向链表中的位置吗?
- 插入顺序模式(默认):不会改变,仅覆盖值,双向链表位置保持首次插入的顺序;
- 访问顺序模式:会改变,重复put属于“访问节点”,节点会被移到双向链表尾部,成为最新访问节点。
七、核心总结
- LinkedHashMap是HashMap的有序子类,底层为数组+链表/红黑树 + 双向链表,继承HashMap的所有特性(支持null、O(1)效率、红黑树优化),额外实现有序性;
- 核心通过重写HashMap的钩子方法维护双向链表,支持插入顺序(默认)和访问顺序两种模式,插入顺序保留插入先后,访问顺序是LRU缓存的核心;
- 有序遍历基于双向链表实现,效率高于HashMap,containsValue方法因跳过空桶也更高效;
- 仅需重写removeEldestEntry方法即可快速实现LRU缓存,是JDK原生最简洁的LRU实现方式;
- 非线程安全,低并发用Collections.synchronizedMap包装,高并发需手动加锁或使用第三方库(Guava/Caffeine);
- 是有序Map的首选实现,比TreeMap效率更高,适用于保留插入/访问顺序、LRU缓存等场景,无需按键排序的有序场景均优先选择;
- 面试核心考点:底层实现、两种有序模式、LRU缓存实现、与HashMap/TreeMap的区别,是Java集合框架中子类增强父类的经典设计案例。
Hashtable
前置基础:核心特性&设计定位
核心定位
java.util.Hashtable 是Java集合框架中最早的哈希表实现(JDK1.0),实现Map接口,基于数组+链表的经典哈希表结构存储键值对,核心特性是键值均不允许为null、天生线程安全,所有核心方法均加synchronized全局锁,适合低并发的线程安全键值对存储场景。
它是JDK早期的Map实现,后续被JDK1.2的HashMap(非线程安全)和JDK1.5的JUC并发Map(高并发)替代,目前仅用于旧代码兼容,新开发中完全不推荐使用。
核心特性
- 经典哈希表结构:底层是Entry数组+单向链表,通过键的
hashCode()计算哈希值,映射到数组索引,哈希冲突时用单向链表解决; - 天生线程安全:所有核心方法(put/get/remove/contains)均加
synchronized方法级锁,保证多线程操作的原子性; - 键值均不可为null:键(key)和值(value)都不能是null,添加null会直接抛出
NullPointerException; - 固定扩容机制:默认初始容量11,加载因子0.75,扩容为原容量×2 + 1(保证奇数,减少哈希冲突);
- 哈希计算简单:直接使用键的
hashCode(),通过hash % table.length映射数组索引(未做哈希扰动优化); - 快速失败机制:维护
modCount,遍历过程中集合被修改会抛出ConcurrentModificationException; - 继承Dictionary类:JDK1.0的旧集合类,同时实现
Map接口,兼容旧的字典操作方法(如elements()/keys()); - 迭代器支持:提供
entrySet()/keySet()/values()的迭代器,也保留旧的Enumeration遍历方式。
与核心Map的对比(开发/面试必问)
1. Hashtable vs HashMap(最核心对比,同源不同命)
两者均实现Map接口,底层都是数组+链表,核心区别是线程安全、null支持、扩容机制、哈希计算,HashMap是Hashtable的非线程安全优化版,也是新开发的首选:
特性 | Hashtable | HashMap |
线程安全 | 是(synchronized全局锁) | 否(单线程高效) |
null支持 | 键/值均不支持(抛NPE) | 键允许1个null、值允许多个null |
底层结构 | 数组+单向链表(JDK1.0至今) | 数组+链表+红黑树(JDK1.8+,链表≥8转红黑树) |
默认初始容量 | 11 | 16(2的幂) |
扩容机制 | 原容量×2+1(保证奇数) | 原容量×2(保证2的幂,位运算优化索引) |
哈希计算 | 直接用hashCode(),hash%length | 哈希扰动(hashCode()高16位^低16位),hash&(length-1) |
加载因子 | 固定0.75 | 默认0.75,支持自定义 |
继承/实现 | 继承Dictionary+实现Map | 实现Map,继承AbstractMap |
遍历方式 | 迭代器/Enumeration | 迭代器/forEach(JDK8+) |
并发效率 | 极低(全局锁串行执行) | 无锁,单线程效率极高 |
扩容触发 | 元素数 ≥ 容量×加载因子 | 元素数 ≥ 容量×加载因子 |
官方态度 | 弃用(仅兼容旧代码) | 推荐(单线程Map首选) |
2. Hashtable vs ConcurrentHashMap(线程安全Map的新旧对比)
ConcurrentHashMap是JDK1.5为解决Hashtable并发效率低而设计的高并发Map,是线程安全Map的首选,完全替代Hashtable:
特性 | Hashtable | ConcurrentHashMap |
线程安全实现 | synchronized全局锁(方法级) | JDK1.7:分段锁(Segment);JDK1.8+:CAS+synchronized(节点级) |
并发效率 | 极低(所有操作串行) | 极高(多线程并行操作,无全局锁) |
null支持 | 键/值均不支持 | 键/值均不支持 |
底层结构 | 数组+单向链表 | JDK1.8+:数组+链表+红黑树 |
扩容机制 | 原容量×2+1 | 原容量×2(2的幂) |
快速失败 | 支持(遍历抛异常) | 弱一致性(遍历不抛异常,允许脏读) |
适用场景 | 低并发兼容旧代码 | 高/低并发线程安全场景 |
一、底层核心结构
Hashtable的底层是经典的哈希表实现,由Entry数组和单向链表组成,无红黑树优化(JDK所有版本均如此),核心属性和Entry节点定义如下:
1. 核心底层属性
public class Hashtable<K,V> extends Dictionary<K,V> implements Map<K,V>, Cloneable, java.io.Serializable { // 存储键值对的Entry数组,哈希表的“桶” private transient Entry<?,?>[] table; // 哈希表中实际存储的键值对个数 private transient int count; // 扩容阈值 = 容量 × 加载因子,元素数≥阈值时触发扩容 private int threshold; // 加载因子,固定0.75(不可自定义,JDK硬编码) private float loadFactor; // 修改次数,用于快速失败机制 private transient int modCount = 0; // 序列化版本号 private static final long serialVersionUID = 1421746759512286392L; }核心常量:加载因子loadFactor固定为0.75f,是时间和空间的平衡值——减少哈希冲突的同时,避免数组空间浪费。
2. Entry节点定义(单向链表)
Entry是Hashtable的内部静态类,代表哈希表中的一个键值对,构成单向链表解决哈希冲突:
private static class Entry<K,V> implements Map.Entry<K,V> { final int hash; // 键的哈希值(缓存,避免重复计算) final K key; // 键,final修饰不可修改 V value; // 值,可修改 Entry<K,V> next;// 下一个Entry节点,单向链表指针 Entry(int hash, K key, V value, Entry<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } // 重写equals、hashCode、setValue等方法 }单向链表特性:哈希冲突时,新节点插入到链表头部(头插法),遍历从头部开始到尾部结束。
3. 哈希表结构示意图(容量11,加载因子0.75,阈值8)
数组索引:0 1 2 3 4 5 6 7 8 9 10 ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ 桶结构: - A - B→C - D - - - E -- 键A的哈希值映射到索引1,无冲突,直接存为数组元素;
- 键B、C哈希冲突,映射到索引3,形成单向链表(B是头节点,C是后继);
- 当元素数达到8(11×0.75),触发扩容,新容量=11×2+1=23。
二、核心构造方法
Hashtable提供4个构造方法,支持默认初始化、指定初始容量、指定容量+加载因子、传入Map初始化,加载因子默认0.75,且仅在构造时可指定(后续不可修改):
1. 空参构造:new Hashtable<>()
public Hashtable() { // 调用指定容量+加载因子构造,初始容量11,加载因子0.75 this(11, 0.75f); }- 最常用的构造方法,默认容量11、阈值8(11×0.75),满足绝大多数低并发简单场景;
- 无初始元素,
table数组在首次put时初始化(懒加载)。
2. 指定初始容量:new Hashtable(int initialCapacity)
public Hashtable(int initialCapacity) { // 调用指定容量+加载因子构造,加载因子固定0.75 this(initialCapacity, 0.75f); }- 适合已知键值对大致数量的场景,避免频繁扩容;
- 初始容量建议设置为
(预计元素数 / 0.75) + 1,减少扩容次数。
3. 指定初始容量+加载因子:new Hashtable(int initialCapacity, float loadFactor)
public Hashtable(int initialCapacity, float loadFactor) { // 校验参数:容量不能为负,加载因子必须>0且≤1 if (initialCapacity < 0) throw new IllegalArgumentException("Illegal Capacity: " + initialCapacity); if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal Load: " + loadFactor); if (initialCapacity == 0) initialCapacity = 1; this.loadFactor = loadFactor; // 初始化Entry数组,容量为指定值(无需是2的幂) table = new Entry<?,?>[initialCapacity]; // 计算扩容阈值:容量×加载因子,向下取整 threshold = (int)Math.min(initialCapacity * loadFactor, Integer.MAX_VALUE + 1); }- 唯一可自定义加载因子的构造方法,不建议修改0.75(官方优化的平衡值);
- 初始容量支持任意正整数(无需是2的幂),Hashtable会直接使用。
4. 传入Map初始化:new Hashtable(Map<? extends K, ? extends V> t)
public Hashtable(Map<? extends K, ? extends V> t) { // 初始化容量:取t.size()×2和11的最大值,避免频繁扩容 this(Math.max(2*t.size(), 11), 0.75f); // 批量添加Map中的所有键值对(调用putAll) putAll(t); }- 将传入的Map(如HashMap)键值对批量复制到Hashtable中;
- 若传入的Map包含null键/值,会直接抛出
NullPointerException; - 时间复杂度O(n),n为传入Map的元素个数。
关键注意点
键值均不可为null:所有put相关方法都会显式检查key和value是否为null,只要有一个为null就抛NPE:
Hashtable<String, String> ht = new Hashtable<>(); ht.put("name", null); // 抛NullPointerException(value为null) ht.put(null, "Java"); // 抛NullPointerException(key为null)三、核心底层逻辑:哈希计算与扩容机制
1. 哈希计算(简单无优化,易冲突)
Hashtable的哈希计算分为两步,无哈希扰动优化,直接使用键的hashCode(),通过取模映射数组索引,哈希冲突概率较高:
步骤1:计算键的哈希值
直接调用键的hashCode()方法,若key为null直接抛NPE:
int hash = key.hashCode();步骤2:映射到数组索引
通过取模运算将哈希值映射到数组的合法索引(0 ~ table.length-1):
int index = (hash & 0x7FFFFFFF) % table.length;hash & 0x7FFFFFFF:将哈希值转为非负数(避免hash为负时,取模得到负索引);% table.length:取模运算,因table.length是奇数,能减少哈希冲突(比偶数更均匀)。
哈希计算缺陷:未做哈希扰动(如HashMap的高16位^低16位),若键的hashCode()分布较差,极易导致哈希冲突,链表过长会降低查询效率。
2. 扩容机制(固定规则,无优化)
当Hashtable的实际元素数count ≥ 阈值threshold时,添加元素会触发扩容,扩容规则固定为原容量×2 + 1,核心步骤为创建新数组→重新哈希所有元素→替换原数组。
(1)扩容触发时机
所有put操作都会在添加元素后检查count > threshold,满足则触发扩容:
// 扩容核心方法,加synchronized锁 protected void rehash() { int oldCapacity = table.length; // 保存原数组 Entry<?,?>[] oldMap = table; // 计算新容量:原容量×2 + 1(保证奇数) int newCapacity = (oldCapacity << 1) + 1; // 处理容量溢出,设置为Integer.MAX_VALUE if (newCapacity - Integer.MAX_VALUE > 0) { if (oldCapacity == Integer.MAX_VALUE) return; // 容量已达最大值,不再扩容 newCapacity = Integer.MAX_VALUE; } // 创建新容量的Entry数组 Entry<?,?>[] newMap = new Entry<?,?>[newCapacity]; modCount++; // 修改次数+1,触发快速失败 // 计算新阈值:新容量×加载因子,向下取整 threshold = (int)Math.min(newCapacity * loadFactor, Integer.MAX_VALUE + 1); table = newMap; // 核心:重新哈希所有元素,从原数组复制到新数组 for (int i = oldCapacity ; i-- > 0 ;) { for (Entry<K,V> old = (Entry<K,V>)oldMap[i] ; old != null ; ) { Entry<K,V> e = old; old = old.next; // 先保存下一个节点,避免链表断裂 // 重新计算新数组的索引 int index = (e.hash & 0x7FFFFFFF) % newCapacity; // 头插法:新节点插入到新数组索引的链表头部 e.next = (Entry<K,V>)newMap[index]; newMap[index] = e; } } }(2)扩容核心步骤
- 计算新容量:原容量×2+1,保证奇数,若溢出则设为
Integer.MAX_VALUE; - 创建新数组:容量为新容量的Entry数组;
- 更新阈值:新容量×加载因子,向下取整;
- 重新哈希:遍历原数组的所有桶,对每个链表的所有元素重新计算索引,通过头插法插入到新数组的对应桶中;
- 替换原数组:将
table指向新数组,完成扩容。
(3)扩容特性
- 扩容成本高:需要对所有元素重新哈希+复制,时间复杂度O(n),n为元素个数,频繁扩容会严重影响性能;
- 头插法:新节点插入链表头部,原链表的顺序会被反转(无实际影响,仅遍历顺序变化);
- 无红黑树优化:即使链表过长,也不会转为红黑树,查询效率会线性下降(O(n))。
四、核心Map接口方法(线程安全版,O(1)平均复杂度)
Hashtable实现Map接口的所有核心方法,所有方法均加synchronized关键字,保证线程安全,平均时间复杂度O(1)(哈希冲突少时),最坏O(n)(链表过长时)。
1. 新增/修改键值对:put(K key, V value)(核心)
核心作用:添加键值对,若键已存在则覆盖旧值并返回旧值;若键不存在则添加新节点,触发容量检查和扩容,key/value均不能为null。
public synchronized V put(K key, V value) { // 步骤1:检查value是否为null,抛NPE if (value == null) { throw new NullPointerException(); } // 步骤2:检查table数组是否为空,首次put则初始化 Entry<?,?> tab[] = table; // 计算key的哈希值和数组索引(key为null时hashCode()抛NPE) int hash = key.hashCode(); int index = (hash & 0x7FFFFFFF) % tab.length; // 步骤3:遍历对应桶的链表,检查键是否已存在 @SuppressWarnings("unchecked") Entry<K,V> entry = (Entry<K,V>)tab[index]; for(; entry != null ; entry = entry.next) { // 哈希值相同且键equals,覆盖旧值 if ((entry.hash == hash) && entry.key.equals(key)) { V old = entry.value; entry.value = value; return old; } } // 步骤4:键不存在,添加新节点(头插法) addEntry(hash, key, value, index); return null; } // 添加新Entry节点的私有方法 private void addEntry(int hash, K key, V value, int index) { modCount++; // 修改次数+1 Entry<?,?> tab[] = table; // 检查是否需要扩容:元素数≥阈值,且对应桶已有元素 if (count >= threshold) { rehash(); // 扩容 // 扩容后重新计算索引 tab = table; hash = key.hashCode(); index = (hash & 0x7FFFFFFF) % tab.length; } // 头插法:新节点插入到链表头部 @SuppressWarnings("unchecked") Entry<K,V> e = (Entry<K,V>)tab[index]; tab[index] = new Entry<>(hash, key, value, e); count++; // 元素数+1 }核心特性:
- 方法加
synchronized,多线程同时put不会出现元素覆盖; - 自动处理键重复的情况,覆盖旧值并返回,符合Map规范;
- 首次put时初始化数组,懒加载优化。
2. 查询键值对:get(Object key)(核心)
核心作用:根据键查询对应的值,若键不存在返回null;若键为null抛NPE,时间复杂度平均O(1)。
public synchronized V get(Object key) { Entry<?,?> tab[] = table; // 计算哈希值和索引(key为null抛NPE) int hash = key.hashCode(); int index = (hash & 0x7FFFFFFF) % tab.length; // 遍历链表,匹配哈希值和equals for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) { if ((e.hash == hash) && e.key.equals(key)) { return (V)e.value; } } return null; }核心优化:缓存了键的哈希值(Entry.hash),无需重复计算,提升遍历效率。
3. 删除键值对:remove(Object key)(核心)
核心作用:根据键删除对应的键值对,返回被删除的值;若键不存在返回null,需要调整链表指针。
public synchronized V remove(Object key) { Entry<?,?> tab[] = table; int hash = key.hashCode(); int index = (hash & 0x7FFFFFFF) % tab.length; @SuppressWarnings("unchecked") Entry<K,V> e = (Entry<K,V>)tab[index]; Entry<K,V> prev = null; // 遍历链表,找到目标节点 while (e != null) { if ((e.hash == hash) && e.key.equals(key)) { modCount++; // 修改次数+1 // 调整链表指针:删除当前节点 if (prev != null) { prev.next = e.next; } else { tab[index] = e.next; // 目标节点是头节点,直接替换桶的头 } count--; // 元素数-1 V oldValue = e.value; e.value = null; // 清空引用,便于GC回收 return oldValue; } prev = e; e = e.next; } return null; }核心步骤:找到目标节点→调整前驱/后继指针→清空引用→更新计数,保证链表结构完整。
4. 其他核心方法
(1)判空/获取大小:isEmpty()/size()
public synchronized boolean isEmpty() { return count == 0; } public synchronized int size() { return count; }- 均加
synchronized,保证多线程下计数的准确性。
(2)包含判断:containsKey(Object key)/containsValue(Object value)
public synchronized boolean containsKey(Object key) { // 逻辑与get()类似,仅返回是否存在 } public synchronized boolean containsValue(Object value) { // 遍历整个哈希表(所有桶+所有链表),时间复杂度O(n) if (value == null) { throw new NullPointerException(); } Entry<?,?>[] tab = table; for (int i = tab.length ; i-- > 0 ;) { for (Entry<?,?> e = tab[i] ; e != null ; e = e.next) { if (e.value.equals(value)) { return true; } } } return false; }containsValue效率极低,需全表遍历,尽量避免使用。
(3)清空哈希表:clear()
public synchronized void clear() { Entry<?,?>[] tab = table; modCount++; // 遍历所有桶,清空Entry节点的引用,便于GC回收 for (int i = tab.length ; i-- > 0 ;) tab[i] = null; count = 0; // 元素数置0,数组容量不变 }- 仅清空节点引用,不缩容,底层数组容量仍为当前值。
五、遍历方式(两种:迭代器/Enumeration)
Hashtable支持现代迭代器和旧版Enumeration两种遍历方式,均为O(n)时间复杂度,迭代器支持快速失败,Enumeration不支持,新代码推荐使用迭代器,旧代码兼容用Enumeration。
1. 迭代器遍历(推荐,支持Map的标准遍历)
Hashtable提供entrySet()/keySet()/values()三个视图的迭代器,支持边遍历边删除(迭代器的remove()方法),是新代码的首选:
(1)遍历键值对(entrySet,最常用)
Hashtable<String, String> ht = new Hashtable<>(); ht.put("name", "Java"); ht.put("age", "25"); ht.put("gender", "male"); // 遍历所有键值对(推荐) for (Map.Entry<String, String> entry : ht.entrySet()) { String key = entry.getKey(); String value = entry.getValue(); System.out.println(key + "=" + value); } // 迭代器方式,支持边遍历边删除 Iterator<Map.Entry<String, String>> it = ht.entrySet().iterator(); while (it.hasNext()) { Map.Entry<String, String> entry = it.next(); if (entry.getKey().equals("age")) { it.remove(); // 安全删除,无异常 } }(2)遍历键/值
// 遍历所有键 for (String key : ht.keySet()) { System.out.println(key); } // 遍历所有值 for (String value : ht.values()) { System.out.println(value); }2. Enumeration遍历(旧版,仅兼容)
Enumeration是JDK1.0的旧迭代器,仅支持遍历(读),不支持删除,方法名为hasMoreElements()/nextElement(),通过keys()/elements()获取:
// 遍历所有键 Enumeration<String> keyEnum = ht.keys(); while (keyEnum.hasMoreElements()) { String key = keyEnum.nextElement(); System.out.println(key); } // 遍历所有值 Enumeration<String> valueEnum = ht.elements(); while (valueEnum.hasMoreElements()) { String value = valueEnum.nextElement(); System.out.println(value); }遍历注意点
- 快速失败:迭代器遍历过程中,若通过Hashtable的
put/remove修改集合,会抛出ConcurrentModificationException,仅能通过迭代器的remove()修改; - Enumeration无快速失败:遍历过程中集合被修改,不会抛异常,但可能得到脏数据;
- 遍历顺序无序:哈希表的遍历顺序与键的插入顺序无关,由哈希值决定。
六、Hashtable的核心缺陷(面试高频,官方弃用原因)
Hashtable因设计早、优化少,存在多个致命缺陷,也是Java官方弃用、被HashMap/ConcurrentHashMap替代的核心原因,这是面试的高频考点:
缺陷1:线程安全的实现方式导致并发效率极低
Hashtable的线程安全是通过给所有核心方法加synchronized全局锁实现的,相当于给整个Hashtable对象加锁:
- 同一时间只有一个线程能执行任意核心方法(put/get/remove/contains),所有操作串行执行;
- 即使是多个线程的读操作(get),也会互相阻塞,高并发读场景性能极差;
- 组合操作(如
if (!ht.containsKey(key)) { ht.put(key, value); })仍需手动加锁,否则会出现线程安全问题。
缺陷2:哈希计算无优化,哈希冲突概率高
- 直接使用键的
hashCode(),未做任何哈希扰动处理,若键的hashCode()分布较差(如连续整数),极易导致哈希冲突; - 仅通过取模运算映射索引,效率低于HashMap的位运算(
hash & (length-1)); - 无红黑树优化,链表过长时查询效率从O(1)退化到O(n)。
缺陷3:键值均不支持null,使用灵活性低
与HashMap相比,Hashtable禁止null键和null值,添加null会直接抛NPE,导致:
- 无法表示“键不存在”和“值为null”的场景,使用场景受限;
- 与其他Map实现(如HashMap)的兼容性差,无法直接转换。
缺陷4:扩容机制固定,扩容成本高且不灵活
- 扩容规则固定为
原容量×2+1,无法自定义,若初始容量设置不合理,会导致频繁扩容; - 扩容时需要重新哈希所有元素,并通过头插法复制到新数组,时间复杂度O(n),成本极高;
- 加载因子固定为0.75,不可在运行时修改,无法根据实际场景调整时间/空间平衡。
缺陷5:继承Dictionary类,设计冗余且不规范
- Dictionary是JDK1.0的旧集合类,Hashtable同时继承Dictionary和实现Map接口,导致方法冗余(如
put/putEntry、get/getEntry); - 不符合Java集合框架的设计规范(如HashMap继承AbstractMap,仅实现Map接口),代码冗余且维护成本高。
缺陷6:快速失败机制,遍历灵活性低
- 迭代器遍历过程中,任何非迭代器的修改都会触发快速失败,抛
ConcurrentModificationException; - 无法像ConcurrentHashMap那样实现弱一致性遍历(允许脏读,但不抛异常),遍历灵活性低。
七、替代方案(开发首选,完全替代Hashtable)
Hashtable在新代码开发中完全禁止使用,根据是否需要线程安全,有明确的替代方案,优先级从高到低:
1. 无需线程安全(单线程/多线程无并发修改):HashMap(首选)
- HashMap是Hashtable的非线程安全优化版,修复了Hashtable的大部分缺陷(支持null、哈希扰动、位运算索引、红黑树优化);
- 单线程下性能远高于Hashtable,是Java中最常用的Map实现;
- 支持JDK8+的Lambda表达式(forEach),遍历方式更丰富,兼容性更好。
示例:
Map<String, String> map = new HashMap<>(); map.put("name", "Java"); map.put(null, "nullKey"); // 支持null键 map.put("age", null); // 支持null值2. 需要线程安全(低/高并发):ConcurrentHashMap(首选)
- ConcurrentHashMap是线程安全Map的工业标准,JDK1.8+采用CAS+节点级synchronized实现,并发效率远超Hashtable;
- 无全局锁,多线程可并行操作不同的桶,读操作无锁,高并发下性能极佳;
- 支持弱一致性遍历,遍历过程中修改集合不会抛异常,适合高并发场景;
- 与Hashtable一致,键值均不支持null,兼容性好。
示例:
Map<String, String> concurrentMap = new ConcurrentHashMap<>(); concurrentMap.put("name", "Java"); concurrentMap.put("age", "25"); // 高并发下安全操作,无需手动加锁3. 低并发线程安全(兼容旧代码):Collections.synchronizedMap
- 通过集合工具类将HashMap包装为线程安全的Map,底层基于
synchronized全局锁,实现简单; - 并发效率与Hashtable相当,但底层是HashMap,支持null键/值,灵活性更高;
- 仅适合低并发场景,高并发仍推荐ConcurrentHashMap。
示例:
// 包装HashMap为线程安全Map Map<String, String> syncMap = Collections.synchronizedMap(new HashMap<>()); syncMap.put("name", "Java"); // 遍历需手动加锁 synchronized (syncMap) { for (Map.Entry<String, String> entry : syncMap.entrySet()) { System.out.println(entry); } }八、开发/面试高频考点
1. Hashtable的底层实现是什么?核心结构是什么?
- 底层是经典的哈希表,核心结构为Entry数组+单向链表(无红黑树优化);
- 通过Entry数组存储“桶”,哈希冲突时用单向链表解决,新节点采用头插法插入链表头部。
2. Hashtable为什么键值都不能为null?
官方设计的刻意选择,核心原因:
- 避免空指针歧义:
get(key)返回null时,无法区分“键不存在”和“值为null”,Hashtable通过抛NPE彻底避免这种歧义; - 早期设计理念:JDK1.0的集合类普遍不支持null,强调数据的非空性,减少空指针异常;
- 方法实现简化:无需在
put/get/contains等方法中单独处理null的情况,简化代码逻辑。
3. Hashtable和HashMap的核心区别有哪些?(核心考点)
最核心的5个区别:
- 线程安全:Hashtable是(synchronized全局锁),HashMap否;
- null支持:Hashtable键/值均不支持,HashMap键允许1个null、值允许多个null;
- 哈希计算:Hashtable直接用hashCode()+取模,HashMap做哈希扰动+位运算;
- 扩容机制:Hashtable扩容为×2+1(奇数),HashMap扩容为×2(2的幂);
- 底层结构:Hashtable始终是数组+链表,HashMapJDK1.8+是数组+链表+红黑树。
4. Hashtable的线程安全是如何实现的?有什么缺陷?
- 实现方式:所有核心方法加synchronized方法级锁,等价于给Hashtable对象加全局锁,保证单个方法的原子性;
- 核心缺陷:并发效率极低——全局锁导致所有操作串行执行,读操作互相阻塞,高并发下性能极差;且组合操作仍需手动加锁。
5. Hashtable的扩容机制是什么?为什么扩容为原容量×2+1?
- 扩容触发:元素数≥容量×加载因子(0.75)时触发;
- 扩容规则:原容量×2+1,保证新容量为奇数;
- 原因:奇数的容量能让哈希值的取模结果更均匀,减少哈希冲突(偶数容量会导致偶数哈希值集中在偶数索引,奇数哈希值集中在奇数索引)。
6. Hashtable为什么被官方弃用?替代方案是什么?
弃用原因
核心是并发效率极低,加上哈希计算无优化、不支持null、扩容成本高等缺陷,无法满足现代开发的性能和灵活性需求。
替代方案
- 无需线程安全:HashMap(首选);
- 需要线程安全:ConcurrentHashMap(首选,高低并发均适用);
- 低并发兼容:Collections.synchronizedMap(new HashMap<>())。
7. Hashtable的哈希计算过程是怎样的?为什么不如HashMap高效?
计算过程
- 调用key的
hashCode()获取哈希值(key为null抛NPE); - 通过
hash & 0x7FFFFFFF将哈希值转为非负数; - 通过
hash % table.length取模,映射到数组索引。
效率低的原因
- 无哈希扰动处理,若key的hashCode()分布差,极易哈希冲突;
- 取模运算的效率远低于HashMap的位运算(
hash & (length-1)); - 容量为奇数,无法利用位运算优化,只能用取模。
8. Hashtable支持边遍历边删除吗?如何实现?
- 支持,但仅能通过迭代器的
remove()方法实现; - 若直接调用Hashtable的
remove()方法,会修改modCount,触发迭代器的快速失败机制,抛出ConcurrentModificationException; - 旧版的Enumeration遍历不支持删除,仅能读。
9. Hashtable和ConcurrentHashMap的线程安全实现有什么区别?
- Hashtable:synchronized全局锁,方法级加锁,所有操作串行执行,并发效率极低;
- ConcurrentHashMap(JDK1.8+):CAS+节点级synchronized锁,仅对哈希冲突的链表/红黑树节点加锁,多线程可并行操作不同桶,并发效率极高;
- 此外,ConcurrentHashMap是弱一致性,遍历不抛异常;Hashtable是快速失败,遍历抛异常。
九、核心总结
- Hashtable是JDK1.0的经典哈希表实现,底层为Entry数组+单向链表,天生线程安全(synchronized全局锁),键值均不支持null;
- 核心缺陷:并发效率极低、哈希计算无优化、扩容成本高、使用灵活性低,Java官方明确弃用,新开发中完全禁止使用;
- 扩容机制固定:默认容量11、加载因子0.75,扩容为原容量×2+1(保证奇数),扩容时需重新哈希所有元素,成本高;
- 哈希计算简单:直接用hashCode()+取模,无扰动优化,哈希冲突概率较高,且无红黑树优化,链表过长会退化查询效率;
- 遍历方式:支持迭代器(快速失败,推荐)和Enumeration(旧版,仅兼容),迭代器支持边遍历边删除;
- 替代方案:无需线程安全用HashMap,需要线程安全用ConcurrentHashMap(高低并发均适用),低并发兼容用Collections.synchronizedMap;
- 面试核心考点:与HashMap/ConcurrentHashMap的区别、线程安全实现缺陷、扩容机制、null不支持的原因,是Java集合框架中旧版设计的典型代表。
Properties
前置基础:核心特性&设计定位
核心定位
java.util.Properties 是Hashtable的子类,专门用于处理配置文件的键值对集合,核心特性是键和值均为String类型(底层自动做类型转换),并提供了从文件/流加载配置、将配置写入文件/流的便捷方法,是Java中处理**纯文本配置文件(如.properties/.ini)**的原生标准工具。
它继承了Hashtable的线程安全、哈希表结构特性,同时针对配置场景做了轻量化封装,适用于加载项目的基础配置(如数据库连接信息、系统参数、业务配置等),是Java后端开发中最基础、最常用的配置处理类。
核心特性
- 键值均为String:底层虽继承Hashtable(K/V为Object),但对外暴露的核心方法仅支持String类型,自动做Object↔String转换,避免类型转换异常;
- 继承Hashtable:复用哈希表结构,增删查平均O(1) 时间复杂度,天生线程安全(所有方法加
synchronized锁),区别于HashMap/HashSet的非线程安全; - 配置文件专属方法:提供
load()(从文件/流加载配置)、store()(将配置写入文件/流)、loadFromXML()/storeToXML()(XML格式配置)等核心方法,适配配置文件的读写; - 支持默认值获取:提供
getProperty(String key, String defaultValue)方法,键不存在时返回默认值,避免空指针,适配配置缺失场景; - 配置项有序性:JDK1.6+后,Properties底层实际使用LinkedHashtable实现,保留配置项的加载/插入顺序,遍历顺序与配置文件中的顺序一致;
- 支持系统属性关联:提供
getProperty(String key)方法,若自身配置中无该键,会自动从**系统属性(System.getProperties())**中查找,实现配置兜底; - 轻量无依赖:作为JDK核心类(java.util包),无需引入第三方依赖,是处理纯文本配置的原生方案;
- 编码兼容:JDK1.8+的
load(Reader)/store(Writer)支持自定义编码(如UTF-8),解决早期默认ISO-8859-1的中文乱码问题。
与核心Map的对比(开发/面试必问)
Properties是专用型Map,与通用Map(HashMap/Hashtable/LinkedHashMap)的核心区别是定位不同,前者专为配置文件设计,后者为通用键值对存储:
特性 | Properties | Hashtable | LinkedHashMap | HashMap |
键值类型 | 强制String(底层隐式转换) | Object/Object | Object/Object | Object/Object |
核心定位 | 配置文件读写 | 通用线程安全Map | 通用有序Map | 通用高性能Map |
线程安全 | 是(继承Hashtable) | 是(方法加synchronized) | 否 | 否 |
有序性 | 是(JDK1.6+,插入/加载顺序) | 否(JDK1.6前) | 是(插入/访问顺序) | 否 |
配置专属方法 | 有(load/store/XML) | 无 | 无 | 无 |
默认值获取 | 支持(getProperty) | 无 | 无 | 无 |
系统属性关联 | 支持 | 无 | 无 | 无 |
平均时间复杂度 | O(1)(继承哈希表) | O(1) | O(1) | O(1) |
空键/空值支持 | 不支持null键/值 | 不支持null键/值 | 支持1个null键/多null值 | 支持1个null键/多null值 |
适用场景 | 配置文件(.properties)读写 | 高并发通用键值存储 | 有序通用键值存储 | 非并发通用键值存储 |
一、底层核心原理:Hashtable的子类+String键值封装
Properties的底层设计非常简洁,继承Hashtable<Object, Object>,并通过封装专属的String类型方法屏蔽底层的Object类型,同时新增配置文件读写、默认值获取等特性,核心逻辑可总结为:
Hashtable(哈希表+线程安全) + String键值强制转换 + 配置文件IO方法 + 系统属性关联 = Properties
1. 核心类结构与属性
Properties无新增底层哈希表相关属性,完全继承Hashtable的所有属性(如数组、容量、加载因子等),仅新增1个用于父配置兜底的属性,核心类定义如下:
public class Properties extends Hashtable<Object, Object> { // 父配置集合:若当前Properties无指定键,会从父配置中查找(多层配置兜底) protected Properties defaults; // 序列化版本号 private static final long serialVersionUID = 4112578634029874840L; // 构造方法 public Properties() { this(null); } public Properties(Properties defaults) { this.defaults = defaults; } }核心设计巧思:
- 继承Hashtable而非HashMap:为了天生的线程安全,适配配置文件在多线程环境下的加载/读取(如项目启动时的全局配置);
- 保留
defaults父配置:实现多层配置兜底(如全局配置→模块配置→业务配置),键不存在时逐级查找; - 无独立哈希表实现:完全复用Hashtable的哈希表结构、扩容机制(默认容量11,加载因子0.75,扩容为
原容量*2+1),无需重复开发。
2. 核心特性的底层实现
(1)键值均为String:底层隐式转换
Properties对外暴露的**所有核心方法(如getProperty/setProperty)**均强制使用String类型,底层通过Object.toString()和强制类型转换实现Object↔String的转换,若传入非String类型,会自动调用toString():
// 设置键值对,强制String类型 public Object setProperty(String key, String value) { return put(key, value); // 调用Hashtable的put(Object, Object) } // 获取键值对,返回String,键不存在返回null public String getProperty(String key) { Object oval = super.get(key); // 调用Hashtable的get(Object) String sval = (oval instanceof String) ? (String)oval : null; // 自身无值 → 查父配置defaults → 查系统属性System.getProperties() return (sval == null && defaults != null) ? defaults.getProperty(key) : sval; }关键限制:Properties不支持null键和null值(继承Hashtable的特性),若传入null,会抛出NullPointerException。
(2)线程安全:继承Hashtable的synchronized锁
Properties的所有核心方法(setProperty/getProperty/load/store/clear等),要么直接继承Hashtable的加synchronized锁的方法,要么自身加锁,保证所有操作的原子性:
- Hashtable的
put/get/remove/containsKey等方法均被synchronized修饰; - Properties的
load/store等IO方法,自身也添加了synchronized锁,避免多线程同时读写配置文件。
(3)有序性:JDK1.6+底层为LinkedHashtable
JDK1.6之前,Properties的底层是纯Hashtable,遍历无序;JDK1.6及以后,Java官方将Properties的底层实现改为LinkedHashtable(Hashtable的子类,类似LinkedHashMap),在哈希表基础上维护了双向链表,因此保留配置项的加载顺序/插入顺序,遍历顺序与配置文件中的顺序完全一致。
(4)系统属性关联:getProperty的兜底逻辑
getProperty(String key)的核心查找逻辑为三级兜底,是Properties的重要特性:
- 先从当前Properties实例中查找键,存在则返回对应String值;
- 若不存在,且存在
defaults父配置,则从父配置中递归查找; - 若仍不存在,调用
System.getProperty(key)从JVM系统属性中查找(如java.version、user.dir等); - 所有层级均无则返回
null。
3. 底层结构示意图
Properties的底层结构 = Hashtable的数组+链表哈希表 + 双向链表(JDK1.6+) + 三级兜底引用,结合配置文件的读写能力:
// 哈希表结构(继承Hashtable,数组+链表,线程安全) 数组索引:0 1 2 ... 5 ... 10 ↓ ↓ ↓ ↓ ↓ 桶结构: k1=v1 - k2=v2 k3=v3→k4=v4 ... - (所有k/v均为String,底层存储为Object,获取时隐式转换) // 双向链表(JDK1.6+,维护加载/插入顺序) head → k1=v1 → k2=v2 → k3=v3 → k4=v4 → tail // 三级兜底引用 当前Properties → defaults(父配置)→ System.getProperties()(系统属性)二、核心构造方法:极简,支持父配置兜底
Properties仅提供2个构造方法,均为无参/单参,核心作用是初始化实例并指定父配置兜底集合,无容量/加载因子指定方法(复用Hashtable的默认值:初始容量11,加载因子0.75):
1. 无参构造:new Properties()
public Properties() { this(null); // 父配置defaults为null,关闭多层兜底 }- 最常用构造方法,适用于单一层级的配置(如单独加载一个.properties文件);
- 无父配置兜底,
getProperty仅会查找当前实例和系统属性。
2. 带父配置的构造:new Properties(Properties defaults)
public Properties(Properties defaults) { this.defaults = defaults; // 指定父配置,开启多层兜底 }- 适用于多层配置兜底场景(如全局公共配置+模块专属配置);
- 示例:先加载全局配置
globalProps,再加载模块配置moduleProps = new Properties(globalProps),模块配置中未定义的键会从全局配置中查找。
关键注意点
- 无自定义容量/加载因子:Properties不提供指定初始容量、加载因子的构造方法,固定使用Hashtable的默认值(容量11,加载因子0.75),因配置文件的键值对数量通常较少,无需自定义;
- 父配置为浅引用:
defaults仅保存父配置的引用,若父配置后续修改,当前Properties的getProperty会获取到最新值; - 线程安全的初始化:构造方法无锁,但后续的所有操作方法均为线程安全,可在多线程中安全使用。
三、核心方法:三大类(String键值操作/配置IO/辅助方法)
Properties的方法分为三大核心类别,均为配置场景量身设计,摒弃了Hashtable的通用Object方法,对外仅暴露String类型的便捷操作,核心方法覆盖配置的增删查、文件读写、兜底获取全流程。
第一类:String键值核心操作(替代Hashtable的Object方法)
这类方法是Properties的基础,强制键值为String类型,替代了Hashtable的put/get/remove等Object方法,推荐优先使用,避免类型转换问题。
方法名 | 功能 | 核心特点 |
| 添加/修改配置项 | 键存在则覆盖值,返回旧值;键不存在则添加,返回null |
| 获取配置项 | 三级兜底(当前→父配置→系统属性),无则返回null |
| 获取配置项(带默认值) | 最常用!键不存在时直接返回默认值,避免空指针 |
| 删除配置项 | 继承Hashtable,键为Object,实际建议传String |
| 判断是否包含键 | 继承Hashtable,线程安全 |
常用示例
Properties props = new Properties(); // 1. 添加/修改配置项 props.setProperty("db.url", "jdbc:mysql://localhost:3306/test"); props.setProperty("db.username", "root"); props.setProperty("db.password", "123456"); // 2. 获取配置项(带默认值,推荐) String url = props.getProperty("db.url", "jdbc:mysql://localhost:3306/default"); String port = props.getProperty("db.port", "3306"); // 键不存在,返回默认值3306 // 3. 获取配置项(无默认值) String username = props.getProperty("db.username"); // root // 4. 判断是否包含键 boolean hasPwd = props.containsKey("db.password"); // true // 5. 删除配置项 props.remove("db.password");第二类:配置文件IO操作(核心,.properties/XML格式)
这是Properties的核心价值,专门用于从文件/字节流/字符流加载配置,或将配置写入文件/字节流/字符流,支持纯文本.properties和XML两种格式,解决了手动读取配置文件的繁琐。
1. 加载配置:load() 系列(加载.properties纯文本)
用于从字节流/字符流加载.properties格式的配置文件,JDK1.8+推荐使用字符流(Reader),支持自定义编码(如UTF-8),解决中文乱码问题。
void load(InputStream inStream):从字节流加载,默认编码ISO-8859-1,中文会乱码,不推荐;void load(Reader reader):从字符流加载,支持自定义编码(如FileReader指定UTF-8),推荐使用;void load(LineNumberReader lnr):从行号字符流加载,适用于需要定位配置行号的场景。
.properties文件格式规范:
# 这是注释(#/!开头为注释) db.url=jdbc:mysql://localhost:3306/test db.username=root db.password=123456 # 多行值:末尾加\,下一行继续 db.params=useUnicode=true&characterEncoding=utf8\ &autoReconnect=true # 键值分隔符:= / : / 空格(推荐用=) db.driverClass com.mysql.cj.jdbc.Driver加载示例(UTF-8编码,推荐):
Properties props = new Properties(); // 从文件加载,使用FileReader指定UTF-8编码,解决中文乱码 try (Reader reader = new FileReader("src/main/resources/config.properties", StandardCharsets.UTF_8)) { props.load(reader); // 加载配置,保留文件中的顺序 } catch (IOException e) { e.printStackTrace(); }2. 写入配置:store() 系列(写入.properties纯文本)
用于将Properties中的配置项写入文件/字节流/字符流,生成标准的.properties文件,同样推荐使用字符流(Writer) 支持自定义编码。
void store(OutputStream out, String comments):写入字节流,默认ISO-8859-1,中文乱码;void store(Writer writer, String comments):写入字符流,支持自定义编码,推荐;- comments:配置文件的注释,会写入文件头部,传null则无注释。
写入示例(UTF-8编码,推荐):
Properties props = new Properties(); props.setProperty("name", "张三"); props.setProperty("age", "20"); props.setProperty("gender", "男"); // 写入文件,UTF-8编码,注释为"用户配置" try (Writer writer = new FileWriter("user.properties", StandardCharsets.UTF_8)) { props.store(writer, "User Config"); } catch (IOException e) { e.printStackTrace(); }生成的user.properties文件:
#User Config #Mon Jan 29 10:00:00 CST 2026 name=张三 age=20 gender=男3. XML格式配置:loadFromXML()/storeToXML()
支持XML格式的配置文件读写,适用于需要跨语言/跨系统的配置场景,底层自动处理XML的解析和生成,无需手动操作DOM/SAX。
void loadFromXML(InputStream in):从字节流加载XML配置;void storeToXML(OutputStream out, String comments):写入XML配置,默认UTF-8编码;void storeToXML(OutputStream out, String comments, String encoding):自定义编码写入XML。
XML配置文件示例(config.xml):
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd"> <properties> <comment>数据库配置</comment> <entry key="db.url">jdbc:mysql://localhost:3306/test</entry> <entry key="db.username">root</entry> </properties> XML读写示例:
Properties props = new Properties(); // 加载XML配置 try (InputStream in = new FileInputStream("config.xml")) { props.loadFromXML(in); } catch (IOException e) { e.printStackTrace(); } // 写入XML配置(UTF-8,注释为"数据库配置") try (OutputStream out = new FileOutputStream("db.xml")) { props.storeToXML(out, "数据库配置", StandardCharsets.UTF_8.name()); } catch (IOException e) { e.printStackTrace(); }第三类:辅助方法(遍历/转换/系统属性)
用于将Properties转换为其他集合、遍历配置项、操作系统属性等,适配配置的后续处理。
Set<String> stringPropertyNames():获取所有配置项的键集(String类型),推荐遍历使用;Enumeration<?> propertyNames():获取所有键的枚举(底层Hashtable方法),兼容旧代码,不推荐;void list(PrintStream out)/void list(PrintWriter out):将配置项打印到控制台/流,适用于调试;static Properties getProperties():获取JVM系统属性(System.getProperties()的别名);void clear():清空所有配置项,继承Hashtable,线程安全。
遍历配置项示例(推荐stringPropertyNames())
Properties props = new Properties(); props.setProperty("a", "1"); props.setProperty("b", "2"); props.setProperty("c", "3"); // 推荐:获取String类型键集,增强for遍历 Set<String> keys = props.stringPropertyNames(); for (String key : keys) { String value = props.getProperty(key); System.out.println(key + "=" + value); // 顺序与插入/加载顺序一致 } // 调试:打印所有配置项到控制台 props.list(System.out);四、核心问题:中文乱码解决方案(必看)
Properties处理中文配置时极易出现乱码,核心原因是早期的字节流方法(load(InputStream)/store(OutputStream))默认使用ISO-8859-1编码,该编码不支持中文,导致中文被错误解析。
乱码的两种场景及解决方案
场景1:加载.properties文件时中文乱码
原因:使用load(InputStream)字节流加载,默认ISO-8859-1编码,无法解析中文。
解决方案:使用load(Reader)字符流,通过FileReader/InputStreamReader指定UTF-8编码(推荐),示例:
// 正确:InputStreamReader指定UTF-8编码(兼容所有流场景) try (InputStream in = new FileInputStream("config.properties"); Reader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) { props.load(reader); } // 简化:FileReader直接指定UTF-8(JDK11+支持,推荐) try (Reader reader = new FileReader("config.properties", StandardCharsets.UTF_8)) { props.load(reader); }场景2:写入.properties文件时中文乱码
原因:使用store(OutputStream)字节流写入,默认ISO-8859-1编码,中文被转码为乱码。
解决方案:使用store(Writer)字符流,通过FileWriter/OutputStreamWriter指定UTF-8编码,示例:
// 正确:OutputStreamWriter指定UTF-8编码 try (OutputStream out = new FileOutputStream("config.properties"); Writer writer = new OutputStreamWriter(out, StandardCharsets.UTF_8)) { props.store(writer, "中文配置"); } // 简化:FileWriter直接指定UTF-8(JDK11+支持) try (Writer writer = new FileWriter("config.properties", StandardCharsets.UTF_8)) { props.store(writer, "中文配置"); }终极避坑原则
JDK1.8及以后,始终使用字符流(Reader/Writer)的load/store方法,指定UTF-8编码,彻底避免中文乱码问题,摒弃字节流(InputStream/OutputStream)的相关方法。
五、典型使用场景(开发实战)
Properties是Java原生的配置处理工具,适用于轻量级、纯文本、无复杂结构的配置场景,是项目启动时加载基础配置的首选,以下是最常用的实战场景:
场景1:加载项目本地.properties配置文件(最常用)
加载项目resources目录下的配置文件(如数据库配置、系统参数),结合类加载器获取资源流,适配项目打包后的jar包场景:
// 加载resources/config/db.properties配置文件(UTF-8) Properties dbProps = new Properties(); // 类加载器获取资源流(推荐,适配jar包) try (InputStream in = PropertiesDemo.class.getClassLoader().getResourceAsStream("config/db.properties"); Reader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) { dbProps.load(reader); // 获取数据库配置 String url = dbProps.getProperty("db.url"); String username = dbProps.getProperty("db.username"); String password = dbProps.getProperty("db.password"); } catch (IOException e) { throw new RuntimeException("加载数据库配置失败", e); }场景2:多层配置兜底(全局+模块)
通过Properties(Properties defaults)实现全局公共配置和模块专属配置的兜底,模块配置覆盖全局配置,未定义的键从全局配置中查找:
// 1. 加载全局公共配置 Properties globalProps = new Properties(); try (Reader reader = new FileReader("global.properties", StandardCharsets.UTF_8)) { globalProps.load(reader); // 含:app.name=JavaDemo, app.version=1.0 } // 2. 加载模块配置,指定全局配置为父配置 Properties userProps = new Properties(globalProps); try (Reader reader = new FileReader("user-module.properties", StandardCharsets.UTF_8)) { userProps.load(reader); // 含:user.maxAge=20,无app.name/app.version } // 3. 获取配置:模块有则取模块,无则取全局 String userMaxAge = userProps.getProperty("user.maxAge"); // 20(模块配置) String appName = userProps.getProperty("app.name"); // JavaDemo(全局配置兜底) String appVersion = userProps.getProperty("app.version"); // 1.0(全局配置)场景3:写入自定义配置文件
动态生成/修改配置文件(如用户个性化配置、运行时参数保存),将内存中的Properties写入本地文件:
// 动态创建配置项 Properties userConfig = new Properties(); userConfig.setProperty("user.id", "1001"); userConfig.setProperty("user.name", "张三"); userConfig.setProperty("user.theme", "dark"); userConfig.setProperty("user.language", "zh-CN"); // 写入用户目录下的配置文件 String userHome = System.getProperty("user.home"); // 获取用户主目录 File configFile = new File(userHome, ".javademo/user.config"); // 确保父目录存在 if (!configFile.getParentFile().exists()) { configFile.getParentFile().mkdirs(); } // 写入文件(UTF-8) try (Writer writer = new FileWriter(configFile, StandardCharsets.UTF_8)) { userConfig.store(writer, "User Personal Config"); } catch (IOException e) { e.printStackTrace(); }场景4:读取JVM系统属性
通过Properties的getProperty或直接使用System.getProperties()读取JVM的系统属性(如Java版本、用户目录、项目路径):
Properties sysProps = System.getProperties(); // 读取系统属性 String javaVersion = sysProps.getProperty("java.version"); // Java版本 String userDir = sysProps.getProperty("user.dir"); // 项目运行目录 String osName = sysProps.getProperty("os.name"); // 操作系统名称 // 直接通过System.getProperty获取(更简洁) String javaHome = System.getProperty("java.home"); // JDK安装目录六、开发/面试高频考点
1. Properties的底层实现是什么?核心特性是什么?(核心考点)
- 底层是java.util.Hashtable的子类,JDK1.6+后底层为LinkedHashtable,继承了Hashtable的哈希表结构、线程安全、不支持null键值特性;
- 核心特性:键值均为String类型、提供配置文件IO专属方法(load/store)、支持默认值获取、三级兜底查找(当前→父配置→系统属性)、JDK1.6+保留插入/加载顺序、天生线程安全。
2. Properties和HashMap/Hashtable的核心区别是什么?
- 定位不同:Properties是配置文件专用Map,HashMap/Hashtable是通用键值对Map;
- 键值类型:Properties强制String类型,HashMap/Hashtable支持任意Object类型;
- 配置方法:Properties提供load/store/XML等配置IO方法,HashMap/Hashtable无;
- 兜底逻辑:Properties支持父配置和系统属性兜底,HashMap/Hashtable无;
- 有序性:JDK1.6+Properties有序,Hashtable无序,LinkedHashMap有序;
- null支持:Properties/Hashtable不支持null键值,HashMap支持1个null键/多null值;
- 线程安全:Properties/Hashtable天生线程安全,HashMap/LinkedHashMap非线程安全。
3. Properties处理中文时为什么会乱码?如何解决?(高频)
- 乱码原因:早期的
load(InputStream)和store(OutputStream)方法默认使用ISO-8859-1编码,该编码不支持中文,导致中文被错误解析/转码; - 解决方案:JDK1.8及以后,始终使用字符流(Reader/Writer)的load/store方法,并明确指定UTF-8编码,摒弃字节流的相关方法。
4. Properties的getProperty方法的查找逻辑是什么?
getProperty(String key)采用三级兜底查找,顺序为:
- 先从当前Properties实例中查找,存在则返回String值;
- 若不存在且存在
defaults父配置,则从父配置中递归查找; - 若仍不存在,调用
System.getProperty(key)从JVM系统属性中查找; - 所有层级均无则返回
null。
5. Properties是线程安全的吗?为什么?
- 是线程安全的;
- 原因:Properties继承自Hashtable,Hashtable的所有核心方法(put/get/remove/containsKey)均被synchronized修饰,Properties的专属方法(load/store/getProperty)也自身添加了synchronized锁,保证所有操作的原子性。
6. JDK1.6前后,Properties的有序性有什么变化?
- JDK1.6之前:Properties的底层是纯Hashtable,哈希表结构决定了遍历无序,与插入/加载顺序无关;
- JDK1.6及以后:Java官方将Properties的底层实现改为LinkedHashtable(Hashtable的子类),在哈希表基础上维护了双向链表,因此保留配置项的插入/加载顺序,遍历顺序与配置文件中的顺序完全一致。
7. Properties支持null键和null值吗?为什么?
- 不支持,调用
setProperty(null, "value")或setProperty("key", null)会直接抛出NullPointerException; - 原因:Properties继承自Hashtable,Hashtable的设计原则是拒绝null键和null值,底层会在put/get时做null校验,避免空指针异常。
8. 如何加载项目resources目录下的.properties文件?适配jar包场景。
- 推荐使用**类加载器的
getResourceAsStream(String path)**方法获取资源流,该方法会自动查找resources目录下的文件,适配项目打包后的jar包场景(jar包中的资源无法通过File访问); - 示例:
PropertiesDemo.class.getClassLoader().getResourceAsStream("config/db.properties"); - 注意:资源路径无需加
/,直接写相对路径,且使用字符流加载并指定UTF-8编码。
9. Properties和yml/yaml配置文件的区别?适用场景是什么?
Properties是纯文本键值对配置,yml/yaml是层级化结构化配置,二者互补,适用场景不同:
特性 | Properties | Yml/Yaml |
结构 | 扁平键值对(key=value) | 层级化(缩进表示层级) |
可读性 | 简单配置友好,复杂配置繁琐 | 复杂层级配置友好,可读性高 |
数据类型 | 仅支持String,需手动转换 | 原生支持String/int/bool/list/map |
解析工具 | JDK原生支持(Properties类) | 需第三方依赖(如Spring Boot的snakeyaml) |
适用场景 | 轻量级、简单、无层级的基础配置 | 复杂、层级化、多数据类型的业务配置 |
- Properties:Java原生无依赖,适合项目启动的基础配置(如数据库、端口、系统参数);
- yml/yaml:适合Spring Boot/Spring Cloud等框架的业务配置,支持复杂的层级和数据类型。
七、核心总结
- Properties是Hashtable的子类,Java原生的配置文件专用Map,核心设计为哈希表+String键值封装+配置IO方法+线程安全,是处理.properties纯文本配置的标准工具;
- 核心特性:键值均为String、天生线程安全、JDK1.6+有序(加载/插入顺序)、三级兜底查找、配置文件读写专属方法、不支持null键值;
- 核心避坑:始终使用字符流(Reader/Writer)的load/store方法并指定UTF-8编码,彻底解决中文乱码问题,摒弃字节流方法;
- 核心方法:
setProperty(增改)、getProperty(key, defaultValue)(获取,推荐带默认值)、load(Reader)(加载)、store(Writer)(写入)、stringPropertyNames()(遍历); - 加载资源:推荐使用**类加载器的
getResourceAsStream**加载项目resources目录下的配置文件,适配jar包场景; - 适用场景:轻量级、无复杂结构、纯文本的配置(如数据库、端口、系统参数),JDK原生无依赖,是项目基础配置的首选;
- 与yml/yaml的区别:Properties是扁平键值对,JDK原生支持;yml/yaml是层级化结构,需第三方依赖,二者互补,分别适用于简单和复杂配置场景。
我可以帮你整理Properties核心方法速查表,包含所有常用操作的代码示例和避坑点,需要吗?
HashSet
前置基础:核心特性&设计定位
核心定位
java.util.HashSet 是Java集合框架中基于HashMap实现的无序、不可重复的Set集合,JDK1.2引入,实现Set接口,本质是HashMap的“键集”包装器——将Set的元素作为HashMap的键,值固定为一个静态常量PRESENT,通过HashMap的键唯一性实现Set的不可重复性,通过HashMap的哈希表结构实现O(1)平均时间复杂度的增删查。
它是最常用的Set实现,非线程安全,支持null元素(仅一个),无序且遍历顺序不固定(由元素哈希值决定),适用于无需有序、仅需去重的绝大多数场景,是Set接口的工业标准实现。
核心特性
- 基于HashMap实现:无独立底层结构,直接复用HashMap的数组+链表/红黑树哈希表、哈希计算、扩容机制、null支持等,仅封装了Set的接口方法;
- 不可重复:依托HashMap的键唯一性实现——添加重复元素时,HashMap的put方法会覆盖旧键,HashSet则返回
false,保证元素唯一; - 无序:遍历顺序与元素插入顺序、自然顺序均无关,由元素的
hashCode()和HashMap的哈希表结构决定,且扩容后遍历顺序可能改变; - 支持null元素:与HashMap一致,仅允许一个null元素(作为HashMap的键);
- 非线程安全:无同步锁,多线程并发修改会抛出
ConcurrentModificationException,高并发需手动加锁; - 平均O(1)时间复杂度:增(add)、删(remove)、查(contains)的平均时间复杂度均为O(1),最坏O(n)(哈希冲突严重,链表过长);
- 无索引:实现Set接口,无List的索引操作(如get(int index)),无法通过索引访问元素;
- 快速失败机制:继承HashMap的
modCount,遍历过程中修改集合会触发快速失败; - 轻量封装:仅维护一个HashMap实例和一个静态常量值,代码极简,无额外性能开销。
与核心Set的对比(开发/面试必问)
1. HashSet vs LinkedHashSet(无序vs有序Set)
LinkedHashSet是HashSet的子类,基于LinkedHashMap实现,核心区别是是否有序,其余特性(不可重复、null支持、O(1)效率)一致:
特性 | HashSet | LinkedHashSet |
底层实现 | 基于HashMap | 基于LinkedHashMap |
有序性 | 无序(哈希表顺序) | 有序(插入顺序/访问顺序) |
底层结构 | 数组+链表/红黑树 | 数组+链表/红黑树+双向链表 |
遍历效率 | 中(需遍历哈希表空桶) | 高(基于双向链表,无空桶) |
内存占用 | 低 | 稍高(节点多2个指针) |
插入性能 | 极致O(1)(无额外维护) | 略低(维护双向链表) |
适用场景 | 纯去重,无需有序 | 去重+保留插入顺序 |
重复元素处理 | 覆盖键,返回false | 覆盖键,返回false |
2. HashSet vs TreeSet(哈希表vs红黑树Set)
TreeSet基于TreeMap实现,底层是红黑树,核心区别是有序性、实现原理、时间复杂度,是有序Set的两大实现:
特性 | HashSet | TreeSet |
底层实现 | 基于HashMap(哈希表) | 基于TreeMap(红黑树) |
有序性 | 无序 | 有序(自然顺序/自定义Comparator) |
不可重复实现 | 哈希值+equals | 自然比较/Comparator |
null元素支持 | 允许1个null | 不支持null(抛ClassCastException) |
时间复杂度 | 平均O(1),最坏O(n) | O(logn)(红黑树操作) |
元素要求 | 重写hashCode+equals | 实现Comparable/自定义Comparator |
遍历方式 | 哈希表遍历 | 红黑树中序遍历 |
适用场景 | 纯去重,追求极致性能 | 去重+按键排序 |
扩容机制 | 哈希表扩容(×2) | 无扩容,动态平衡红黑树 |
3. HashSet vs ArraySet(JDK vs Android)
ArraySet是Android系统提供的Set实现,底层是数组,适用于小数据量场景,JDK中无此实现:
特性 | HashSet | ArraySet(Android) |
底层实现 | 哈希表 | 有序数组 |
时间复杂度 | 平均O(1) | O(logn)(二分查找) |
内存占用 | 高(哈希表冗余) | 低(无冗余) |
数据量适配 | 大数据量 | 小数据量(<1000) |
插入性能 | 大数据量更优 | 小数据量更优 |
有序性 | 无序 | 有序(自然顺序) |
一、底层核心原理:HashMap的包装器
HashSet的核心设计是**“复用HashMap实现Set”,无自己的底层结构,仅通过一个HashMap实例存储元素,将Set的元素作为HashMap的键**,值固定为一个私有的静态常量PRESENT(Object类型),通过HashMap的键特性实现Set的所有核心功能。
这种设计属于组合模式(通过组合HashMap实现功能,而非继承),避免了继承HashMap带来的方法冗余,同时严格遵循Set接口的规范。
1. 核心底层属性
HashSet的源码极简,仅3个核心属性,全部围绕HashMap展开:
public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable { // 核心:存储HashSet元素的HashMap实例,元素作为键,值为PRESENT private transient HashMap<E,Object> map; // 固定值:所有HashMap键对应的默认值,节省内存(静态常量,唯一实例) private static final Object PRESENT = new Object(); // 序列化版本号 private static final long serialVersionUID = -5024744406713321676L; }核心设计巧思:
PRESENT是静态常量,所有HashSet实例共享同一个对象,无需为每个键创建新值,大幅节省内存;- 仅维护HashMap的引用,无额外数据结构,实现了Set接口的“最小封装”。
2. 底层结构示意图
HashSet的底层结构就是HashMap的哈希表结构,元素仅作为HashMap的键存在,值固定为PRESENT:
// HashSet的元素:A、B、C、null(对应HashMap的键) HashMap键:null A - B→C - ... (数组+链表结构,与HashMap一致) HashMap值:PRESENT PRESENT PRESENT ... (所有值均为同一个PRESENT)- 元素null作为HashMap的键,存储在哈希表的索引0位置;
- 元素B、C哈希冲突,形成链表,作为HashMap的键串联;
- 所有键对应的值都是同一个
PRESENT,无实际业务意义。
3. 核心特性的底层映射
HashSet的所有特性均由HashMap的特性直接映射而来,无需额外实现:
HashSet特性 | 底层HashMap的实现依据 |
不可重复 | HashMap的键唯一性(hashCode+equals) |
无序 | HashMap的哈希表遍历顺序(由hashCode决定) |
支持一个null元素 | HashMap允许一个null键 |
平均O(1)时间复杂度 | HashMap的哈希表增删查效率 |
快速失败机制 | 继承HashMap的modCount |
扩容机制(默认16,0.75) | 复用HashMap的扩容规则 |
二、核心构造方法
HashSet提供4个构造方法,全部通过创建对应的HashMap实例实现,构造参数与HashMap完全一致,仅封装了Set的接口,无额外逻辑:
1. 空参构造:new HashSet<>()
public HashSet() { // 创建默认的HashMap实例(容量16,加载因子0.75) map = new HashMap<>(); }- 最常用构造方法,默认容量16、加载因子0.75,满足绝大多数纯去重场景;
- HashMap采用懒加载,首次add元素时才初始化底层数组。
2. 指定初始容量/加载因子:new HashSet(int initialCapacity)/new HashSet(int initialCapacity, float loadFactor)
public HashSet(int initialCapacity) { // 创建指定容量的HashMap(加载因子0.75) map = new HashMap<>(initialCapacity); } public HashSet(int initialCapacity, float loadFactor) { // 创建指定容量+加载因子的HashMap,参数直接透传 map = new HashMap<>(initialCapacity, loadFactor); }- 适合已知元素大致数量的场景,避免频繁扩容,提升性能;
- 初始容量建议设置为
(预计元素数 / 0.75) + 1,与HashMap的容量计算规则一致,减少扩容次数。
3. 传入Collection初始化:new HashSet(Collection<? extends E> c)
public HashSet(Collection<? extends E> c) { // 创建HashMap,容量取c.size()/0.75和16的最大值,避免扩容 map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16)); // 批量添加集合元素(调用addAll,底层是HashMap的put) addAll(c); }- 将传入的Collection(如List、ArrayList)元素批量去重后存入HashSet;
- 若传入的Collection包含重复元素,仅保留一个(依托HashMap的键唯一性);
- 时间复杂度O(n),n为传入Collection的元素个数。
关键注意点
- 与HashMap参数完全一致:HashSet的构造参数本质是HashMap的构造参数,无自己的参数规则;
- 仅支持一个null元素:添加多个null元素时,后续的add操作会返回
false,仅保留一个null; - 元素的唯一性要求:存入HashSet的元素必须重写
hashCode()和equals()方法,否则会导致重复元素(HashMap无法判断键是否相同)。
三、核心Set接口方法:全部委托给HashMap
HashSet实现Set接口的所有核心方法,无任何自己的实现逻辑,全部委托给底层的HashMap,方法的入参/返回值适配Set接口的规范(如将HashMap的键操作转为Set的元素操作),平均时间复杂度均为O(1)。
1. 添加元素:add(E e)(核心,实现去重)
核心作用:添加元素到Set,若元素已存在则返回false,若不存在则添加并返回true,实现不可重复性。
public boolean add(E e) { // 委托给HashMap的put方法:将元素e作为键,PRESENT作为值 // HashMap.put返回null:键不存在(添加成功),返回旧值:键已存在(添加失败) return map.put(e, PRESENT) == null; }核心逻辑解析:
- 当元素e不存在时,HashMap.put返回
null,因此map.put(...) == null为true,HashSet.add返回true(添加成功); - 当元素e已存在时,HashMap.put会覆盖旧值并返回旧值PRESENT,因此
map.put(...) == null为false,HashSet.add返回false(添加失败,保证唯一)。
关键要求:存入的元素必须重写hashCode()和equals(),否则HashMap无法判断键是否重复,会导致HashSet存入重复元素。
2. 删除元素:remove(Object o)
核心作用:从Set中删除指定元素,若元素存在则删除并返回true,不存在则返回false。
public boolean remove(Object o) { // 委托给HashMap的remove方法:删除键为o的键值对,返回对应的值 // 若返回PRESENT:元素存在(删除成功),返回null:元素不存在(删除失败) return map.remove(o) == PRESENT; }逻辑适配:将HashMap的“删除键并返回值”转换为Set的“删除元素并返回是否存在”。
3. 包含判断:contains(Object o)
核心作用:判断Set中是否包含指定元素,包含返回true,否则返回false,平均时间复杂度O(1)。
public boolean contains(Object o) { // 直接委托给HashMap的containsKey方法(判断键是否存在) return map.containsKey(o); }效率核心:依托HashMap的哈希表查找,无需遍历所有元素,平均O(1)效率。
4. 空判断/获取大小:isEmpty()/size()
public boolean isEmpty() { // 委托给HashMap的isEmpty(判断键值对数量是否为0) return map.isEmpty(); } public int size() { // 委托给HashMap的size(返回键值对数量,即Set的元素个数) return map.size(); }- 无额外计算,直接返回HashMap的状态,时间复杂度O(1)。
5. 清空集合:clear()
public void clear() { // 委托给HashMap的clear(清空所有键值对,哈希表容量不变) map.clear(); }- 仅清空哈希表的节点引用,不缩容,底层数组的容量仍为当前值。
6. 转换为数组:toArray()
public Object[] toArray() { // 委托给HashMap的keySet().toArray(将HashMap的键集转为数组) return map.keySet().toArray(); } public <T> T[] toArray(T[] a) { // 泛型版数组转换,同样委托给HashMap的键集 return map.keySet().toArray(a); }- 转换后的数组是HashMap的键集数组,顺序与HashSet的遍历顺序一致(无序)。
四、遍历方式:与HashMap键集遍历一致
HashSet无独立的遍历实现,所有遍历方式均委托给底层HashMap的键集(keySet),遍历顺序与HashMap的键集遍历顺序一致(无序,由哈希值决定),支持**增强for循环、迭代器、forEach(JDK8+)**三种方式,均为O(n)时间复杂度,无普通for循环(无索引,不支持)。
1. 增强for循环(推荐,语法简洁)
HashSet<String> set = new HashSet<>(); set.add("Java"); set.add("Python"); set.add("C++"); set.add(null); // 支持一个null // 增强for循环:底层基于HashMap键集的迭代器,无序遍历 for (String s : set) { System.out.println(s); // 输出顺序不固定,可能为:null, Java, C++, Python }注意:遍历顺序与插入顺序无关,且扩容后遍历顺序可能改变。
2. 迭代器遍历(推荐,支持边遍历边删除)
HashSet的迭代器是HashMap键集的迭代器,支持快速失败机制,且仅能通过迭代器的remove()方法边遍历边删除(唯一安全方式):
Iterator<String> it = set.iterator(); while (it.hasNext()) { String s = it.next(); if (s == null || s.equals("Python")) { it.remove(); // 迭代器删除,无ConcurrentModificationException } }核心注意:
- 禁止直接调用
set.remove(),会修改modCount,触发快速失败,抛出ConcurrentModificationException; - 迭代器不支持
add()方法(Set的迭代器均为只读,除了remove)。
3. forEach方法(JDK8+,推荐,Lambda风格)
// forEach:底层基于迭代器,支持Lambda表达式,语法简洁 set.forEach(s -> System.out.println(s));- 与增强for循环一致,无序遍历,不支持边遍历边删除。
遍历注意点
- 无序性:所有遍历方式的顺序均由元素的
hashCode()和HashMap的哈希表结构决定,与插入顺序无关; - 快速失败:遍历过程中,若通过非迭代器方式(如set.add/remove)修改集合,会立即抛出
ConcurrentModificationException; - null元素处理:遍历中需注意null元素,避免空指针异常(如调用s.equals()前需判断s != null);
- 无索引遍历:Set接口无索引,无法通过普通for循环(i++)遍历,这是Set与List的核心区别。
五、元素的唯一性:hashCode() + equals() 重写要求
HashSet的不可重复性完全依赖HashMap的键唯一性,而HashMap判断键是否相同的规则是:
两个对象的hashCode()返回值相等且equals(Object o)返回true,则认为是同一个键。
因此,存入HashSet的元素必须正确重写hashCode()和equals()方法,否则会导致重复元素存入,违背Set的设计初衷。
1. 未重写的问题:重复元素存入
若元素类未重写hashCode()和equals(),则使用Object类的默认实现:
hashCode():返回对象的内存地址哈希值,每个新对象的hashCode()都不同;equals():判断两个对象的内存地址是否相同(即this == o)。
此时,即使两个对象的属性完全相同,HashMap也会认为是不同的键,HashSet会存入重复元素:
// 自定义类:未重写hashCode()和equals() class Student { private String name; private int age; public Student(String name, int age) { this.name = name; this.age = age; } } // 测试:属性相同的两个对象被认为是不同元素 public static void main(String[] args) { HashSet<Student> set = new HashSet<>(); set.add(new Student("张三", 20)); set.add(new Student("张三", 20)); // 未重写,会被存入,Set大小为2 System.out.println(set.size()); // 输出:2(错误,预期为1) }2. 正确重写的原则(通用规范)
重写hashCode()和equals()需遵循三大原则,保证HashMap/HashSet的正确工作:
(1)equals相等,hashCode必须相等
若两个对象通过equals()判断为相等,则它们的hashCode()必须返回相同的值(否则HashMap会将其放入不同的桶,无法判断重复)。
(2)hashCode相等,equals不一定相等
hashCode相等仅表示两个对象的哈希值相同,可能发生哈希冲突,需通过equals()进一步判断是否为同一个对象。
(3)重写equals时,必须同时重写hashCode
单独重写其中一个会导致原则1被破坏,引发重复元素问题。
3. 正确重写示例(Student类)
基于对象的业务主键(如name+age)重写,保证属性相同的对象hashCode相等且equals为true:
class Student { private String name; private int age; public Student(String name, int age) { this.name = name; this.age = age; } // 重写equals:基于name和age判断相等 @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Student student = (Student) o; return age == student.age && Objects.equals(name, student.name); } // 重写hashCode:基于name和age计算哈希值 @Override public int hashCode() { return Objects.hash(name, age); // JDK7+的Objects工具类,简洁高效 } } // 测试:重复元素被过滤 public static void main(String[] args) { HashSet<Student> set = new HashSet<>(); set.add(new Student("张三", 20)); set.add(new Student("张三", 20)); // 重写后,添加失败,Set大小为1 System.out.println(set.size()); // 输出:1(正确) }推荐工具:IDEA/Eclipse可自动生成hashCode()和equals()方法,遵循通用规范,无需手动编写。
4. 特殊情况:null元素的唯一性
HashSet允许一个null元素,因为HashMap对null键有特殊处理:
- null键的hashCode()被固定为0,映射到哈希表的索引0位置;
- 多次添加null时,HashMap会判断键已存在,HashSet.add返回false,仅保留一个null。
六、线程安全处理
HashSet非线程安全,与HashMap一致,多线程并发操作(如同时add/remove/遍历)会导致以下问题:
- 数据不一致:多个线程同时add,可能导致重复元素存入(HashMap的put非原子操作);
- 结构错乱:哈希表的链表/红黑树结构被破坏,遍历得到错误结果;
- 快速失败:遍历过程中其他线程修改集合,抛出
ConcurrentModificationException; - 数据丢失:多线程同时remove,可能导致元素被重复删除。
线程安全处理方案(3种,优先级从高到低)
1. 低并发场景:Collections.synchronizedSet包装(推荐,实现简单)
通过集合工具类Collections.synchronizedSet将HashSet包装为线程安全的Set,底层为SynchronizedSet,所有方法均加synchronized全局锁:
// 包装为线程安全的HashSet Set<String> syncSet = Collections.synchronizedSet(new HashSet<>()); // 并发操作:add/remove/get均为线程安全 syncSet.add("Java"); syncSet.remove("Python"); syncSet.contains("C++"); // 遍历需手动加锁(迭代器非线程安全,避免快速失败) synchronized (syncSet) { for (String s : syncSet) { System.out.println(s); } }特点:
- 实现简单,一行代码即可完成包装;
- 并发效率低(全局锁,所有操作串行执行),仅适合低并发场景(如少量线程的简单操作);
- 遍历必须手动加锁,否则仍可能触发快速失败。
2. 高并发场景:ConcurrentHashMap+Collections.newSetFromMap(推荐,高效)
JDK1.6+提供Collections.newSetFromMap方法,可通过ConcurrentHashMap构建线程安全的Set(ConcurrentHashMap是高并发HashMap,节点级锁,并发效率极高):
// 基于ConcurrentHashMap构建线程安全的Set(推荐,高并发首选) Set<String> concurrentSet = Collections.newSetFromMap(new ConcurrentHashMap<>()); // 高并发操作:无需手动加锁,遍历也无需加锁(弱一致性,不抛异常) concurrentSet.add("Java"); concurrentSet.remove("Python"); for (String s : concurrentSet) { System.out.println(s); }核心优势:
- 依托ConcurrentHashMap的高并发特性(CAS+节点级synchronized),多线程可并行操作,并发效率远高于synchronizedSet;
- 遍历采用弱一致性,不会抛出
ConcurrentModificationException,无需手动加锁; - 与HashSet特性一致:支持一个null元素、O(1)平均时间复杂度、无序。
这是JDK原生高并发Set的首选实现。
3. 高并发有序场景:CopyOnWriteArraySet(适合遍历多、修改少)
CopyOnWriteArraySet是JUC提供的线程安全Set,底层基于CopyOnWriteArrayList,采用写时复制策略:
// 写时复制的线程安全Set Set<String> cowSet = new CopyOnWriteArraySet<>(); cowSet.add("Java"); cowSet.forEach(s -> System.out.println(s));特点:
- 写操作(add/remove)时复制整个数组,读/遍历操作无锁,效率极高;
- 适合遍历操作远多于修改操作的高并发场景(如日志收集、配置查询);
- 缺点:写操作成本高(复制数组),数据存在弱一致性(遍历的是旧数组);
- 有序:遍历顺序与插入顺序一致,无哈希表的无序性。
七、开发/面试高频考点
1. HashSet的底层实现是什么?核心原理是什么?(核心考点)
- 底层基于HashMap实现,无独立的底层结构,是HashMap的“键集包装器”;
- 核心原理:将Set的元素作为HashMap的键,值固定为一个静态常量
PRESENT,通过HashMap的**键唯一性(hashCode+equals)**实现Set的不可重复性,通过HashMap的哈希表结构实现O(1)的平均时间复杂度。
2. HashSet如何保证元素的唯一性?
- 依托HashMap的键唯一性实现,而HashMap判断键是否相同的规则是:两个对象的hashCode()相等 且 equals(Object o)返回true;
- 当添加元素时,HashSet.add会委托给HashMap.put,若键已存在(hashCode+equals相同),put返回旧值PRESENT,add返回false,元素添加失败,保证唯一性。
3. 存入HashSet的元素为什么要重写hashCode()和equals()?如果只重写其中一个会怎样?
重写的原因
Object类的默认hashCode()返回对象内存地址的哈希值,equals()判断内存地址是否相同,若不重写,即使两个对象属性完全相同,HashMap也会认为是不同的键,导致HashSet存入重复元素。
只重写一个的问题
- 仅重写equals,未重写hashCode:两个equals相等的对象,hashCode可能不同,HashMap会将其放入不同的桶,无法判断为同一个键,导致重复元素;
- 仅重写hashCode,未重写equals:两个hashCode相等的对象,equals可能返回false,HashMap会认为是不同的键(哈希冲突),导致重复元素。
结论:必须同时重写hashCode()和equals(),且遵循“equals相等则hashCode必相等”的原则。
4. HashSet支持null元素吗?支持几个?
- 支持,仅允许一个null元素;
- 原因:HashMap允许一个null键,HashSet将元素作为HashMap的键,多次添加null时,HashMap会判断键已存在,HashSet.add返回false,仅保留一个null。
5. HashSet是有序的吗?为什么?
- 无序;
- 原因:HashSet的遍历顺序由底层HashMap的键集遍历顺序决定,而HashMap的键集顺序由元素的
hashCode()和哈希表的桶结构决定,与元素的插入顺序、自然顺序均无关,且扩容后遍历顺序可能改变。
6. HashSet和HashMap的关系是什么?
- 组合关系:HashSet通过组合HashMap实现所有功能,而非继承,属于设计模式中的组合模式;
- 包装器:HashSet是HashMap的“键集包装器”,仅暴露Set接口的方法,隐藏HashMap的值操作;
- 特性完全映射:HashSet的所有特性(不可重复、null支持、O(1)效率、扩容机制)均由HashMap的特性决定。
7. HashSet的add方法返回false的情况有哪些?
两种情况会导致add返回false,元素添加失败:
- 元素已存在:该元素的hashCode()和equals()与Set中已有元素相同,HashMap.put返回旧值PRESENT;
- 添加重复的null:Set中已存在null元素,再次添加null时,HashMap判断键已存在。
8. HashSet与LinkedHashSet、TreeSet的核心区别?如何选择?(核心考点)
核心区别
维度 | HashSet | LinkedHashSet | TreeSet |
底层实现 | HashMap(哈希表) | LinkedHashMap(哈希表+双向链表) | TreeMap(红黑树) |
有序性 | 无序 | 有序(插入顺序) | 有序(自然/自定义顺序) |
唯一性实现 | hashCode+equals | hashCode+equals | Comparable/Comparator |
null支持 | 允许1个null | 允许1个null | 不支持null |
时间复杂度 | 平均O(1) | 平均O(1) | O(logn) |
内存占用 | 低 | 稍高 | 中 |
元素要求 | 重写hashCode+equals | 重写hashCode+equals | 实现Comparable/自定义Comparator |
选择原则
- 选HashSet:绝大多数场景的首选,纯去重、无需有序、追求极致性能;
- 选LinkedHashSet:需要去重+保留插入顺序的场景(如日志去重、按插入顺序遍历);
- 选TreeSet:需要去重+按键排序的场景(如数据排序、排行榜、范围查询)。
9. HashSet是线程安全的吗?如何实现线程安全?(高频)
- 非线程安全,无任何同步锁;
- 线程安全方案:
- 低并发:
Collections.synchronizedSet(new HashSet<>())(实现简单); - 高并发:
Collections.newSetFromMap(new ConcurrentHashMap<>())(推荐,效率极高); - 遍历多修改少:
CopyOnWriteArraySet(写时复制,读无锁)。
10. HashSet的扩容机制是什么?
- HashSet无独立的扩容机制,完全复用HashMap的扩容规则;
- 默认初始容量16,加载因子0.75,扩容阈值=容量×0.75;
- 当元素个数≥扩容阈值时,扩容为原容量×2(保证2的幂),重新哈希所有元素并复制到新数组;
- 初始容量建议设置为
(预计元素数 / 0.75) + 1,减少扩容次数。
八、核心总结
- HashSet是基于HashMap实现的无序、不可重复Set,本质是HashMap的键集包装器,无独立底层结构,所有功能均委托给HashMap;
- 核心特性:不可重复(hashCode+equals)、无序、支持一个null、平均O(1)时间复杂度、非线程安全,是Set接口的最常用实现;
- 底层巧思:将元素作为HashMap的键,值固定为静态常量PRESENT,节省内存,实现最小封装;
- 关键要求:存入的元素必须同时重写hashCode()和equals(),否则会导致重复元素,违背Set的设计初衷;
- 遍历方式:支持增强for、迭代器、forEach,均为无序遍历,迭代器支持边遍历边删除(仅能通过it.remove());
- 线程安全:低并发用Collections.synchronizedSet包装,高并发首选基于ConcurrentHashMap构建的Set,遍历多修改少用CopyOnWriteArraySet;
- 选型原则:纯去重选HashSet,去重+插入顺序选LinkedHashSet,去重+排序选TreeSet,HashSet是绝大多数场景的首选;
- 面试核心考点:底层实现、元素唯一性的保证、hashCode+equals的重写要求、与HashMap/LinkedHashSet/TreeSet的区别、线程安全处理,是Java集合框架中组合模式的经典案例。
LinkedHashSet
前置基础:核心特性&设计定位
核心定位
java.util.LinkedHashSet 是HashSet的子类,JDK1.4引入,底层基于LinkedHashMap实现,是有序、不可重复的Set集合。它完全复用HashSet的包装器设计,将Set元素作为LinkedHashMap的键,值固定为常量PRESENT,通过LinkedHashMap的哈希表+双向链表结构,既保留了HashSet的O(1)平均时间复杂度、去重特性、null支持,又实现了插入顺序的有序遍历,解决了HashSet遍历无序的问题。
它是去重+有序场景的首选Set实现,非线程安全,遍历效率高于HashSet,仅在维护双向链表上增加少量性能开销,适用于需要保留元素插入顺序且去重的场景(如日志去重、配置项有序存储、按添加顺序遍历去重数据等)。
核心特性
- 基于LinkedHashMap实现:继承HashSet,将底层HashMap替换为LinkedHashMap,复用其数组+链表/红黑树+双向链表结构,无独立底层逻辑;
- 有序性:默认维护插入顺序(唯一有序模式),遍历顺序与元素首次插入顺序完全一致,重复添加已存在元素不会改变其在有序链表中的位置;
- 不可重复:与HashSet一致,依托LinkedHashMap的**键唯一性(hashCode+equals)**实现,添加重复元素返回
false; - 继承HashSet所有特性:支持一个null元素、非线程安全、平均O(1)增删查时间复杂度、快速失败机制、无索引访问;
- 遍历效率更高:基于LinkedHashMap的双向链表遍历,跳过哈希表的空桶,遍历节点数等于实际元素数,效率远高于HashSet的哈希表遍历;
- 轻量继承:仅重写了HashSet的所有构造方法,将底层Map初始化为LinkedHashMap,无额外代码,完全复用父类的add/remove/contains等方法;
- 扩容不破坏有序:扩容时复用LinkedHashMap的逻辑,双向链表的节点顺序在复制时保持不变,有序性不会因扩容丢失;
- 内存占用稍高:因LinkedHashMap的节点多
before/after双向链表指针,内存占用略高于HashSet,但开销极小。
与核心Set的对比(开发/面试必问)
这是Set接口最核心的三个实现类对比,覆盖了无序、插入有序、排序有序所有场景,是选型的关键依据:
特性 | HashSet | LinkedHashSet | TreeSet |
底层实现 | HashMap(哈希表) | LinkedHashMap(哈希表+双向链表) | TreeMap(红黑树) |
有序性 | 无序(哈希表顺序) | 有序(插入顺序,唯一) | 有序(自然/自定义比较顺序) |
唯一性实现 | 键的hashCode+equals | 键的hashCode+equals | 元素的Comparable/Comparator |
null元素支持 | 允许1个null | 允许1个null | 不支持null(抛异常) |
平均时间复杂度 | 增删查O(1) | 增删查O(1)(稍高,维护双向链表) | 增删查O(logn)(红黑树操作) |
遍历效率 | 中(遍历哈希表,含空桶) | 高(遍历双向链表,无空桶) | 中(红黑树中序遍历) |
内存占用 | 最低 | 稍高(节点多2个指针) | 中等 |
元素要求 | 重写hashCode+equals | 重写hashCode+equals | 实现Comparable/自定义Comparator |
扩容机制 | 哈希表×2(2的幂) | 哈希表×2(2的幂) | 无扩容,动态平衡红黑树 |
适用场景 | 纯去重,无需有序 | 去重+保留插入顺序 | 去重+按键排序/范围查询 |
一、底层核心原理:HashSet+LinkedHashMap
LinkedHashSet的设计是双重复用的经典案例,无任何自己的核心实现,核心逻辑可总结为:
继承HashSet,将父类中的HashMap替换为LinkedHashMap,通过LinkedHashMap的插入顺序特性实现有序,复用HashSet的Set接口包装逻辑实现去重。
简单来说,LinkedHashSet = HashSet的Set接口封装 + LinkedHashMap的插入有序特性。
1. 核心底层属性
LinkedHashSet无自己的成员属性,完全继承HashSet的所有属性(map、PRESENT),仅通过构造方法将map的实际类型从HashMap改为LinkedHashMap:
// LinkedHashSet无新增属性,所有属性均来自HashSet public class LinkedHashSet<E> extends HashSet<E> implements Set<E>, Cloneable, java.io.Serializable { private static final long serialVersionUID = -2851667679971038690L; } // 父类HashSet的核心属性 public class HashSet<E> { private transient HashMap<E,Object> map; // 实际指向LinkedHashMap实例 private static final Object PRESENT = new Object(); }2. 底层结构示意图
底层就是LinkedHashMap的结构,元素作为LinkedHashMap的键,值为PRESENT,哈希表负责O(1)增删查,双向链表负责维护插入顺序:
// 哈希表结构(数组+链表,与HashMap/LinkedHashMap一致) 数组索引:0 1 2 ... 5 ... 15 ↓ ↓ ↓ ↓ ↓ 桶结构:null A - ... B→C ... - (键:Set元素,值:PRESENT) // 双向链表结构(维护插入顺序:null→A→B→C,LinkedHashMap的核心) head → null (before:null, after:A) → A (before:null, after:B) → B (before:A, after:C) → C (before:B, after:null) → tail- 哈希表:解决元素的高效增删查,处理哈希冲突;
- 双向链表:仅维护元素的首次插入顺序,与哈希表结构互不干扰,增删元素时同步更新。
3. 核心特性的底层映射
LinkedHashSet的所有特性均由HashSet和LinkedHashMap共同提供,无额外实现:
LinkedHashSet特性 | 底层实现依据 |
不可重复 | HashSet的包装逻辑+LinkedHashMap的键唯一性(hashCode+equals) |
插入有序 | LinkedHashMap的双向链表维护插入顺序 |
支持一个null元素 | LinkedHashMap允许一个null键 |
平均O(1)时间复杂度 | LinkedHashMap的哈希表结构 |
遍历效率高 | LinkedHashMap的双向链表遍历(无空桶) |
快速失败机制 | 继承LinkedHashMap的modCount |
扩容不破坏有序 | LinkedHashMap扩容时保留双向链表顺序 |
二、核心构造方法:重写并初始化LinkedHashMap
LinkedHashSet仅重写了HashSet的所有4个构造方法,核心逻辑是将父类HashSet中的map属性初始化为LinkedHashMap,所有构造参数均透传给LinkedHashMap,无任何自己的逻辑,代码极简。
注:HashSet的构造方法原本是初始化HashMap,LinkedHashSet通过重写构造方法,替换为LinkedHashMap,这是其实现有序的唯一关键操作。
1. 空参构造:new LinkedHashSet<>()
public LinkedHashSet() { // 调用父类HashSet的构造方法,初始化LinkedHashMap(容量16,加载因子0.75) super(16, 0.75f, true); }- 父类HashSet的包私有构造方法(
HashSet(int initialCapacity, float loadFactor, boolean dummy))是专门为LinkedHashSet设计的,dummy为占位符,仅用于区分其他构造方法,作用是初始化LinkedHashMap而非HashMap。
2. 指定初始容量/加载因子:new LinkedHashSet(int initialCapacity)/new LinkedHashSet(int initialCapacity, float loadFactor)
public LinkedHashSet(int initialCapacity) { // 初始化LinkedHashMap(指定容量,加载因子0.75) super(initialCapacity, 0.75f, true); } public LinkedHashSet(int initialCapacity, float loadFactor) { // 初始化LinkedHashMap(指定容量+加载因子,透传参数) super(initialCapacity, loadFactor, true); }- 与HashSet的容量规则一致,初始容量建议设置为
(预计元素数 / 0.75) + 1,减少扩容次数; - 加载因子默认0.75(时间/空间的平衡值),非特殊场景无需修改。
3. 传入Collection初始化:new LinkedHashSet(Collection<? extends E> c)
public LinkedHashSet(Collection<? extends E> c) { // 初始化LinkedHashMap,容量取c.size()/0.75和16的最大值,避免扩容 super(Math.max((int) (c.size()/.75f) + 1, 16), 0.75f, true); // 复用HashSet的addAll方法,批量添加并去重,双向链表按传入集合的迭代顺序构建 addAll(c); }- 将传入的Collection(如List、HashSet)元素批量去重后存入,双向链表按传入集合的迭代顺序构建;
- 若传入的是有序集合(如ArrayList),则保留其顺序;若为无序集合(如HashSet),则保留其哈希遍历顺序。
关键注意点
- 仅支持插入顺序:LinkedHashSet的底层是LinkedHashMap,但仅使用其插入顺序模式(
accessOrder=false),不支持访问顺序,因此无法像LinkedHashMap那样实现LRU缓存; - 无访问顺序模式:LinkedHashSet没有提供指定
accessOrder的构造方法,这是与LinkedHashMap的核心区别,也是Set接口的设计初衷(Set无需访问顺序); - 继承的方法完全复用:LinkedHashSet没有重写HashSet的
add/remove/contains/size等任何核心方法,所有操作均通过父类HashSet委托给底层的LinkedHashMap。
三、核心方法:完全复用HashSet,无新增/重写
LinkedHashSet没有重写HashSet的任何核心方法,add/remove/contains/clear/size等所有Set接口方法,均直接继承自HashSet,而HashSet又将这些方法委托给底层的LinkedHashMap。
所有方法的逻辑、时间复杂度、返回值规则与HashSet完全一致,唯一的区别是底层执行的是LinkedHashMap的方法,在增删元素时会同步维护双向链表的插入顺序。
1. 添加元素:add(E e)(去重+保留插入顺序)
// 直接继承HashSet的add方法,无任何修改 public boolean add(E e) { return map.put(e, PRESENT) == null; }核心逻辑:
- 元素不存在:LinkedHashMap.put将元素作为键插入,同时将节点添加到双向链表尾部,返回
null,add返回true; - 元素已存在:LinkedHashMap.put覆盖旧值(仍为PRESENT),返回旧值
PRESENT,add返回false,且不会改变该元素在双向链表中的位置(保留首次插入顺序); - 重复添加null:与HashSet一致,仅保留一个null,add返回
false。
2. 删除元素:remove(Object o)(同步维护双向链表)
// 直接继承HashSet的remove方法 public boolean remove(Object o) { return map.remove(o) == PRESENT; }核心逻辑:
- 元素存在:LinkedHashMap.remove从哈希表中删除节点,同时将该节点从双向链表中移除,保证双向链表结构完整,返回
PRESENT,remove返回true; - 元素不存在:返回
null,remove返回false。
3. 包含判断:contains(Object o)(O(1)效率)
// 直接继承HashSet的contains方法 public boolean contains(Object o) { return map.containsKey(o); }- 依托LinkedHashMap的哈希表查找,平均时间复杂度O(1),与HashSet效率一致;
- 比TreeSet的O(logn)效率高,是有序Set中查询效率最高的实现。
4. 其他核心方法(均继承自HashSet)
方法名 | 功能 | 时间复杂度 |
| 获取元素个数 | O(1) |
| 判断是否为空 | O(1) |
| 清空所有元素 | O(n) |
| 转换为数组(插入顺序) | O(n) |
| 获取迭代器(插入顺序) | O(1) |
关键特性:toArray()返回的数组顺序、迭代器的遍历顺序,均与元素首次插入顺序完全一致。
四、遍历方式:有序遍历(插入顺序),效率更高
LinkedHashSet的遍历方式与HashSet语法完全一致(增强for、迭代器、forEach),但遍历顺序是固定的插入顺序,且遍历效率远高于HashSet,原因是其遍历基于LinkedHashMap的双向链表,无需遍历哈希表的空桶,直接遍历所有实际元素。
所有遍历方式的时间复杂度均为O(n)(n为实际元素数),而HashSet的遍历时间复杂度是O(m)(m为哈希表容量,m≥n),当哈希表稀疏时,LinkedHashSet的遍历效率提升明显。
1. 增强for循环(推荐,语法简洁,插入顺序)
LinkedHashSet<String> lhs = new LinkedHashSet<>(); lhs.add("Java"); lhs.add("Python"); lhs.add("C++"); lhs.add(null); lhs.add("Python"); // 重复添加,顺序不变 // 遍历顺序:Java → Python → C++ → null(与首次插入顺序完全一致) for (String s : lhs) { System.out.println(s); }2. 迭代器遍历(推荐,支持边遍历边删除)
LinkedHashSet的迭代器是LinkedHashMap键集的迭代器,基于双向链表实现,支持快速失败机制,仅能通过迭代器的remove()方法边遍历边删除(唯一安全方式):
Iterator<String> it = lhs.iterator(); while (it.hasNext()) { String s = it.next(); if (s == null || s.equals("C++")) { it.remove(); // 安全删除,不破坏双向链表结构 } } // 删除后遍历:Java → Python(仍保持剩余元素的插入顺序)注意:禁止直接调用lhs.remove(),会修改modCount,触发ConcurrentModificationException。
3. forEach方法(JDK8+,Lambda风格,插入顺序)
// 底层基于迭代器,按插入顺序遍历,语法简洁 lhs.forEach(s -> System.out.println(s));遍历核心优势
- 顺序固定:无论哈希表如何扩容、哈希冲突如何处理,遍历顺序始终与首次插入顺序一致;
- 效率更高:跳过哈希表的空桶,直接遍历实际元素,哈希表越稀疏,效率优势越明显;
- 无额外开销:双向链表的遍历是线性的,无需任何哈希计算,遍历过程简单高效。
五、元素的唯一性:与HashSet完全一致(hashCode+equals)
LinkedHashSet的不可重复性与HashSet、LinkedHashMap完全一致,依托键的hashCode()和equals()方法判断元素是否相同,因此存入LinkedHashSet的元素必须同时重写这两个方法,否则会导致重复元素存入。
1. 重写要求(与HashSet一致)
必须遵循三大原则:
equals()相等的两个对象,hashCode()必须返回相同值;hashCode()相等的两个对象,equals()不一定相等(哈希冲突);- 重写
equals()时,必须同时重写hashCode()。
2. 未重写的问题(与HashSet一致)
若元素类未重写这两个方法,会使用Object类的默认实现:
hashCode():返回对象的内存地址哈希值,每个新对象的哈希值都不同;equals():判断两个对象的内存地址是否相同(this == o)。
此时,即使两个对象的属性完全相同,LinkedHashMap也会认为是不同的键,LinkedHashSet会存入重复元素,违背Set的设计初衷。
3. 正确重写示例(与HashSet通用)
基于对象的业务主键重写,保证属性相同的对象满足hashCode()相等且equals()为true:
class User { private String id; private String name; public User(String id, String name) { this.id = id; this.name = name; } // 基于业务主键id重写equals @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; User user = (User) o; return Objects.equals(id, user.id); } // 基于id重写hashCode @Override public int hashCode() { return Objects.hash(id); } } // 测试:属性id相同的User会被去重,保留首次插入顺序 public static void main(String[] args) { LinkedHashSet<User> lhs = new LinkedHashSet<>(); lhs.add(new User("1", "张三")); lhs.add(new User("1", "张三")); // 重复,添加失败 lhs.add(new User("2", "李四")); System.out.println(lhs.size()); // 输出:2(正确去重) }4. null元素的处理(与HashSet一致)
LinkedHashSet允许一个null元素,因为LinkedHashMap对null键有特殊处理:
- null键的哈希值固定为0,映射到哈希表索引0位置;
- 多次添加null时,LinkedHashMap判断键已存在,LinkedHashSet.add返回
false,仅保留一个null,且null在双向链表中的位置为首次插入的位置。
六、线程安全处理
LinkedHashSet非线程安全,与HashSet、LinkedHashMap一致,多线程并发操作(同时add/remove/遍历)会导致以下问题:
- 数据不一致:多线程同时add,可能导致重复元素存入(LinkedHashMap的put非原子操作);
- 双向链表结构错乱:增删元素时未同步,导致有序链表断裂,遍历得到错误顺序;
- 快速失败:遍历过程中其他线程修改集合,抛出
ConcurrentModificationException; - 数据丢失:多线程同时remove,可能导致元素被重复删除,或链表指针异常。
其线程安全处理方案与HashSet完全一致,但因LinkedHashSet是有序Set,需注意有序性的线程安全(即多线程操作后,遍历顺序仍符合预期),推荐方案分低并发和高并发,优先级明确。
线程安全处理方案(3种,按优先级排序)
1. 低并发场景:Collections.synchronizedSet包装(实现简单,首选)
通过Collections.synchronizedSet将LinkedHashSet包装为线程安全的Set,底层为SynchronizedSet,所有方法加synchronized全局锁,保证单操作原子性:
// 包装为线程安全的LinkedHashSet,保留插入顺序 Set<String> syncLhs = Collections.synchronizedSet(new LinkedHashSet<>()); // 并发操作:add/remove/contains均为线程安全 syncLhs.add("Java"); syncLhs.remove("Python"); // 遍历必须手动加锁(迭代器非线程安全,避免快速失败) synchronized (syncLhs) { for (String s : syncLhs) { System.out.println(s); } }特点:
- 一行代码实现,无需额外开发;
- 并发效率低(全局锁,所有操作串行),仅适合低并发场景(如少量线程的简单增删查);
- 能保证有序性的线程安全,操作后遍历顺序仍为插入顺序。
2. 高并发场景:ConcurrentLinkedHashMap+Collections.newSetFromMap(推荐,高效)
JDK原生无高并发的LinkedHashMap,但可通过Guava的ConcurrentLinkedHashMap(线程安全的LinkedHashMap)结合Collections.newSetFromMap,构建高并发、有序、线程安全的Set,保留插入顺序和O(1)效率:
// 引入Guava依赖,创建ConcurrentLinkedHashMap实例 Map<String, Object> concurrentLinkedMap = new ConcurrentLinkedHashMap.Builder<String, Object>() .initialCapacity(16) .build(); // 基于高并发有序Map构建线程安全的LinkedHashSet Set<String> concurrentLhs = Collections.newSetFromMap(concurrentLinkedMap); // 高并发操作:无需手动加锁,保留插入顺序,遍历无快速失败 concurrentLhs.add("Java"); concurrentLhs.add("Python"); for (String s : concurrentLhs) { System.out.println(s); // 仍为插入顺序 }特点:
- 依托ConcurrentLinkedHashMap的节点级锁,多线程可并行操作,并发效率极高;
- 保留插入顺序,是高并发有序Set的最佳实现;
- 需引入Guava依赖(Maven/Gradle),JDK原生不支持。
3. 遍历多修改少场景:CopyOnWriteArraySet(有序,写时复制)
CopyOnWriteArraySet是JUC提供的线程安全Set,底层基于CopyOnWriteArrayList,采用写时复制策略,遍历顺序与插入顺序一致,适合遍历操作远多于修改操作的高并发场景:
// 写时复制的线程安全有序Set Set<String> cowSet = new CopyOnWriteArraySet<>(); cowSet.add("Java"); cowSet.add("Python"); cowSet.add("C++"); // 高并发遍历:无锁,效率极高,顺序为插入顺序 cowSet.forEach(s -> System.out.println(s)); // 写操作:复制整个数组,成本较高,适合修改少的场景 cowSet.remove("Python");特点:
- 读/遍历无锁,效率远超其他方案,无快速失败机制;
- 写操作(add/remove)复制整个数组,成本高,仅适合修改频率极低的场景;
- 天然有序(插入顺序),无需依赖LinkedHashSet,是JDK原生的高并发有序Set方案。
七、开发/面试高频考点
1. LinkedHashSet的底层实现是什么?核心原理是什么?(核心考点)
- 底层是HashSet的子类,基于LinkedHashMap实现,无独立底层结构;
- 核心原理:继承HashSet的包装器设计(元素作为Map的键,值为PRESENT),将父类中的HashMap替换为LinkedHashMap,通过LinkedHashMap的哈希表+双向链表结构,既依托哈希表实现O(1)增删查和去重(hashCode+equals),又依托双向链表实现插入顺序的有序遍历。
2. LinkedHashSet和HashSet的核心区别是什么?为什么LinkedHashSet是有序的?
核心区别
- 有序性:LinkedHashSet是插入有序,遍历顺序与首次插入顺序一致;HashSet是无序,遍历顺序由哈希值决定;
- 底层实现:LinkedHashSet基于LinkedHashMap(哈希表+双向链表);HashSet基于HashMap(仅哈希表);
- 遍历效率:LinkedHashSet遍历效率更高(双向链表,无空桶);HashSet遍历效率较低(哈希表,含空桶);
- 内存占用:LinkedHashSet稍高(节点多before/after指针);HashSet内存占用更低。
有序的原因
LinkedHashSet的底层LinkedHashMap维护了一条双向链表,所有元素节点都加入该链表,新元素始终插入到链表尾部,重复添加不改变节点位置,删除元素同步移除链表节点,遍历基于该双向链表实现,因此保证了插入顺序。
3. LinkedHashSet支持访问顺序吗?为什么?
- 不支持;
- 原因:1. LinkedHashSet的设计目标是保留插入顺序,Set接口的使用场景无需访问顺序;2. LinkedHashSet仅重写了构造方法,将LinkedHashMap初始化为插入顺序模式(accessOrder=false),未提供任何设置访问顺序的入口;3. 若需要访问顺序的Set,需自定义实现(无原生支持)。
4. LinkedHashSet和TreeSet的核心区别?如何选择有序Set?(高频)
核心区别
- 有序实现:LinkedHashSet是插入顺序(双向链表,无需排序,天然有序);TreeSet是比较顺序(红黑树,基于Comparable/Comparator排序);
- 时间复杂度:LinkedHashSet增删查平均O(1);TreeSet增删查O(logn);
- 元素要求:LinkedHashSet要求重写hashCode+equals;TreeSet要求元素实现Comparable或自定义Comparator;
- null支持:LinkedHashSet允许1个null;TreeSet不支持null(抛ClassCastException);
- 适用场景:LinkedHashSet用于去重+保留插入顺序;TreeSet用于去重+按键排序/范围查询。
选择原则
- 选LinkedHashSet:需要去重且保留元素的插入顺序,无需排序,追求O(1)效率(绝大多数有序Set场景);
- 选TreeSet:需要去重且按元素的自然顺序/自定义顺序排序,或需要范围查询(如ceiling/floor/higher)。
5. LinkedHashSet重复添加已存在的元素,会改变其在有序链表中的位置吗?
- 不会;
- 原因:重复添加已存在的元素时,LinkedHashMap的put方法仅会覆盖值(仍为PRESENT),不会执行任何双向链表的节点移动操作,元素在双向链表中的位置始终为首次插入的位置,保证了插入顺序的唯一性。
6. LinkedHashSet的扩容会破坏有序性吗?为什么?
- 不会;
- 原因:LinkedHashSet的扩容复用LinkedHashMap的扩容逻辑,扩容时仅将原哈希表的节点复制到新数组,节点的before/after双向链表指针保持不变,双向链表的整体顺序与原顺序完全一致,因此有序性不会因扩容丢失。
7. LinkedHashSet需要重写hashCode和equals吗?为什么?
- 需要,且必须同时重写;
- 原因:LinkedHashSet的不可重复性依托LinkedHashMap的键唯一性实现,而LinkedHashMap判断键是否相同的规则是:
hashCode()相等且equals()返回true。若不重写,会使用Object类的默认实现(基于内存地址),导致属性相同的对象被认为是不同元素,存入重复数据。
8. LinkedHashSet是线程安全的吗?如何实现线程安全的有序Set?
- 非线程安全,无任何同步锁;
- 线程安全有序Set的实现方案:
- 低并发:
Collections.synchronizedSet(new LinkedHashSet<>())(实现简单,全局锁); - 高并发:Guava的
ConcurrentLinkedHashMap+Collections.newSetFromMap(高效,保留插入顺序); - 遍历多修改少:JUC的
CopyOnWriteArraySet(写时复制,读无锁,天然插入有序)。
9. LinkedHashSet的构造方法有什么特点?为什么要重写所有构造方法?
- 特点:LinkedHashSet仅重写了构造方法,无任何其他重写/新增方法,所有构造方法均调用父类HashSet的包私有构造方法,将底层Map初始化为LinkedHashMap;
- 原因:HashSet的默认构造方法是初始化HashMap,LinkedHashSet需要通过重写构造方法,替换底层Map的实现为LinkedHashMap,这是其实现插入有序的唯一关键操作,因此必须重写所有构造方法,保证无论通过哪种方式创建LinkedHashSet,底层都是LinkedHashMap。
10. LinkedHashSet和LinkedHashMap的关系是什么?
- 底层依赖关系:LinkedHashSet的底层是LinkedHashMap,所有操作均委托给LinkedHashMap;
- 包装与被包装关系:LinkedHashSet是LinkedHashMap的Set接口包装器,将LinkedHashMap的键集暴露为Set,隐藏值操作(值固定为PRESENT);
- 特性继承关系:LinkedHashSet的所有特性(有序、去重、null支持、O(1)效率)均由LinkedHashMap提供,HashSet仅做了接口封装。
八、核心总结
- LinkedHashSet是HashSet的子类,基于LinkedHashMap实现的插入有序、不可重复Set,无独立底层结构,是Set接口中去重+有序场景的首选实现;
- 核心设计:双重复用,复用HashSet的Set包装器逻辑(元素为键,值为PRESENT),复用LinkedHashMap的哈希表+双向链表结构(O(1)效率+插入有序);
- 核心特性:插入有序(唯一有序模式)、不可重复(hashCode+equals)、支持一个null、平均O(1)增删查、遍历效率高、非线程安全,重复添加元素不改变其有序位置,扩容不破坏有序性;
- 关键实现:仅重写了HashSet的所有构造方法,将底层HashMap替换为LinkedHashMap,无任何其他重写/新增方法,所有核心操作均继承自HashSet并委托给LinkedHashMap;
- 元素要求:存入的元素必须同时重写hashCode()和equals(),否则会导致重复元素,违背Set的设计初衷;
- 遍历方式:支持增强for、迭代器、forEach,均为插入顺序的有序遍历,基于双向链表实现,效率远高于HashSet;
- 线程安全:低并发用
Collections.synchronizedSet包装,高并发用Guava的ConcurrentLinkedHashMap构建,遍历多修改少用CopyOnWriteArraySet; - 选型原则:纯去重选HashSet,去重+插入顺序选LinkedHashSet,去重+排序选TreeSet,三者覆盖了Set的所有主流使用场景;
- 面试核心:底层实现、与HashSet/TreeSet的区别、有序性的实现原理、hashCode+equals的重写要求、线程安全方案,是Java集合框架中复用设计的经典案例。