参考:天天用的 HashMap 还不懂原理?万字图文带你透彻解读
HashMap
1.HashMap底层数据结构
- JDK1.7 HashMap是以数组+链表的形式组成
- JDK1.8以后是以数组+链表+红黑树的组成结构
- 当链表长度大于8 并且 数组长度大于64时,链表结构会转换成红黑树
- HashMap中每一个元素又称为哈希桶,即key-value的实例,在jdk1.7中叫Entry,JDK1.8中叫Node
- Hash桶中包含了4个字段:hash、key、value、next指针
2.get(key)方法原理
- 对key进行hash计算,找到对应的hash桶,为空直接返回null
- 判断第一个元素是否是要查询的元素(hash值相等+key值相等),相等直接返回值
- 如果第一个节点是树结构(first instanceod TreeNode),直接用getTreeNode搜索红黑树。
- 否则(即为链表结构)直接循环遍历(hash值相等+key值相等)查找。
3.put(key,value)方法原理
- 对key进行hash计算,找到对应的hash桶,如果为空,直接插入(tab[i] = newNode(hash,key,value,null))
- 先判断hash桶第一个元素是否是对应的已存在的key(hash值相等+key值相等),是的话直接覆盖。
- 若不相等,则判断当前是链表还是红黑树结构,红黑树就按照红黑是的方式插入数据,若存在key则覆盖。
- 链表的话就遍历处理,遍历到链表尾部增加元素(若已存在key则覆盖),然后会判断当前链表的数量是否大于8,是的话直接将链表转换为红黑树
- 最后判断是否超过最大容量(数量超过threshold),超过则直接扩容。
4.扩容resize()方法原理
什么时候扩容
- threashold(临界值) = loadFactor(负载因子)*capacity(容量大小)
- 负载因子默认:0.75 容量大小默认:16
- 即元素个数达到12时扩容,扩容大小是原来的2倍
主要分2步:
- 扩容:创建一个新的Entry数组,长度是原数组的2倍(newCap = oldCap << 1)
- 位运算:原来的元素hash值和原数组长度进行 & 运算,将元素迁移到新的数组中。
JDK1.7会重新计算每个元素的hash值,而jdk1.8通过高位运算(e.hash & oldCap)来确定是否需要移动,比如:
假设key1的信息如下:
key1.hash=10 -> 二进制:0000 1010
oldcap=16 -> 二进制:0001 0000
e.hash & oldCap=0,因为key1的高1位是0,所以这个key在扩容时不会有变化。
假设key2信息如下:
key2.hash=17 -> 二进制:0001 0001
oldcap=16 -> 二进制:0001 0000
e.hash & oldCap=0,因为key2的高1位是1,所以元素在扩容时位置要发生变化,
新下标=原下标位置+原数组长度
HashMap的一些属性
// HashMap 初始化长度16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// HashMap 最大长度
static final int MAXIMUM_CAPACITY = 1 << 30; // 1073741824
// 默认的加载因子 (扩容因子)
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 当链表长度大于此值且数组长度大于 64 时,会从链表转成红黑树
static final int TREEIFY_THRESHOLD = 8;
// 转换链表的临界值,当元素小于此值时,会将红黑树结构转换成链表结构
static final int UNTREEIFY_THRESHOLD = 6;
// 最小树容量
static final int MIN_TREEIFY_CAPACITY = 64;
为什么HashMap初始化长度为16
主要是为了位运算的方便,位与运算比算数计算的效率高,之所以选择 16,是为了服务将 Key 映射到 index 的算法
有公式(Length 是 HashMap 的长度):HashCode(Key) & (Length- 1)
key 为 "book" 的十进制为 3029737 那二进制就是 101110001110101110 1001 HashMap 长度是默认的 16,length - 1 的结果。十进制 : 15;二进制 : 1111
把以上两个结果做与运算:101110001110101110 1001 & 1111 = 1001;1001 的十进制 = 9, 所以 index=9。
HashMap 的默认长度为 16,是为了降低 hash 碰撞的几率
HashMap死循环问题导致CPU100%
主要原因是JDK1.7的头插法导致的。 假设一个index位置有一个长度为3的链表,从头到尾分别是:A->B->C,假设t1和t2线程在执行的时候,发生了扩容,t1线程先执行,扩容到新的indx时的顺序为C->B->A(头插法),而t2线程此时并没有感知到,此时A依然指向B,那么会发生循环链表的情况,此时在线程t2中的get操作可能会发生死循环。
这个问题在JDK1.8中已经改为尾插法。
ConcurrentHashMap
面试官:说说ConcurrentHashMap底层实现原理?
在JDK1.7中的实现
- JDK1.7中基于数组+链表实现。
- 数组分为:大数组Segment 和小数组HashEntry
- JDK1.7中put的实现
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 在往该 Segment 写入前,先确保获取到锁
HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
V oldValue;
try {
// Segment 内部数组
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
K k;
// 更新已有值...
}
else {
// 放置 HashEntry 到特定位置,如果超过阈值则进行 rehash
// 忽略其他代码...
}
}
} finally {
// 释放锁
unlock();
}
return oldValue;
}
- Segment本身是基于ReentrantLock实现的加锁和释放锁的操作,能保证多个线程访问ConcurrentHashMap时,同一时间只能有一个线程能操作相应的节点。
- ConcurrentHashMap的线程安全是基于Segment加锁实现的,我们将之称为分段锁。
JDK1.8中的实现
- JDK1.8基于数组+链表/红黑树实现
- 链表升级为红黑树的规则:当链表长度>8,并且数组的长度>64时,链表会升级为红黑树结构
- JDK1.8中使用CAS+volatile/Synchroniezed的方式来保证线程安全
final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh; K fk; V fv;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { // 节点为空
// 利用 CAS 去进行无锁线程安全操作,如果 bin 是空的
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break;
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else if (onlyIfAbsent
&& fh == hash
&& ((fk = f.key) == key || (fk != null && key.equals(fk)))
&& (fv = f.val) != null)
return fv;
else {
V oldVal = null;
synchronized (f) {
// 细粒度的同步修改操作...
}
}
// 如果超过阈值,升级为红黑树
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
- 添加元素时,首先会判断容器是否为空,为空则使用volatile+CAS来初始化
- 容器不为空,则根据存储的元素计算该位置是否为空,为空的话则利用CAS在该位置设置该节点。
- 如果不为空则使用Synchronized加锁,遍历桶中的数据,根据要put的key/value是否已存在来决定是替换还是新增
对比
- ConcurrentHashMap JDK1.7中加锁是通过给 Segment 添加 ReentrantLock 锁来实现线程安全的,JDK 1.8 中使用的是数组+链表/红黑树的方式实现的,它是通过 CAS 或 synchronized 来实现线程安全的,并且它的锁粒度更小,查询性能也更高。
- JDK 1.8 使用的是红黑树优化了之前的固定链表,那么当数据量比较大的时候,查询性能也得到了很大的提升,从之前的 O(n) 优化到了 O(logn) 的时间复杂度,
String类为什么设置为final
- 支持字符串常量池,JVM通过字符串常量池复用相同值的String对象,减少内存开销。
String str1 = "Hello";
String str2 = "Hello";
System.out.println(str1 == str2); // 输出 true,因为字符串池中已经存在了 "Hello"
- 提高安全性,数据库的用户名和密码都是字符串,如果字符串可变,被改变字符串指向的对象,造成安全漏洞
- 由于字符串的不可变性,所以是多线程安全的。
- 可以避免继承破坏行为,防止子类覆盖关键方法,子类可能重写equals()、hashCode()等待方法,可能会导致依赖String类的行为异常(如String类型作为key的HashMap)
介绍下Java反射
1.反射基本概念
允许程序在运行时动态地获取类的元信息(如类名、方法、字段),并操作类或对象(如创建实例、调用方法、访问私有成员)。
- 实现框架的灵活性(Spring的依赖注入)
- 是动态代理、序列化等场景的基础
2.反射核心类与作用
| 类名 | 作用 |
|---|---|
| Class | 表示类或接口的元信息,是反射的入口类 |
| Constructor | 表示类的构造方法,用于创建对象实例 |
| Method | 表示类的方法,通过反射调用方法 |
| Field | 表示类的字段(成员变量),可读取或修改字段值 |
| Modifier | 解析类、方法、字段的修饰符(public、static等) |
| Array | 动态创建和操作数组 |
3.核心方法详解
3.1获取Class实例的3种方式
- Class.forName("全限定类名")
//动态加载类
Class<?> clazz= Class.forName("java.lang.String");
- 对象实例.getClass()
//通过对象实例获取
String str = "hello";
Clazz<?> clazz = str.getClass();
- 类名.class
//直接通过类字面量获取
Class<String> clazz = String.class;
3.2 操作构造方法获取实例(Constructor)
- 获取构造方法
// 获取所有 public 构造方法
Constructor<?>[] constructors = clazz.getConstructors();
// 获取指定参数类型的构造方法(包括私有)
Constructor<?> constructor = clazz.getDeclaredConstructor(String.class);
- 通过构造方法创建对象实例
// 调用 public 构造方法
Object obj = constructor.newInstance("参数");
//强制访问private 构造方法
constructor.setAccessible(true);//突破私有权限限制
Object obj = constructor.newInstance();
3.3操作方法(Method)
- 获取方法
//获取所有 public 方法(包括父类)
Method[] methods = clazz.getMethods();
//获取本类声明的方法(包括私有)
Method method = clazz.getDeclaredMethod("方法名",参数类型.class);
- 调用方法
Object result = method.invoke(obj,"参数"); //obj:对象实例
3.4操作字段
- 获取字段
//获取所有public字段
Field[] fields = clazz.getFields();
//获取本类声明的字段(包括private)
Field field = clazz.getDeclaredField("字段名");
- 读取/修改字段值
filed.setAccessible(true);
Object value = field.get(obj);
field.set(obj,"新值");
反射的使用场景
- 动态加载类:如JDBC驱动加载Class.forName("com.mysql.jdbc.Driver");
- 框架设计:Spring通过反射创建Bean并注入依赖
- 单元测试:访问私有方法或者字段进行测试。
- 序列化/反序列化:JSON库(如Jackson)通过反射解析对象结构。
反射注意事项
| 问题 | 注意事项 |
|---|---|
| 性能开销 | 反射比直接调用慢,必要时缓存Class、Method对象 |
| 安全限制 | 通过SecurityManager限制反射权限,防止恶意访问 |
| 破坏封装性 | 谨慎使用setAccessible(true),避免破坏代码健壮性 |
| 兼容性问题 | 反射可能因JDK版本升级失效(如内部类名变化) |