文章目录
-
- 1.一次HTTP请求的完整流程
- [2.GET vs POST 区别](#2.GET vs POST 区别)
- 3.常见状态码
- [4.Cookie vs Session](#4.Cookie vs Session)
- 5.前后端分离如何维护登录态
- 6.==和equals区别
- 7.为什么重写equals一定要重写hashCode
- 8.String为什么不可变
- [9.异常体系(Exception vs RuntimeException)](#9.异常体系(Exception vs RuntimeException))
- 10.ArrayList扩容机制
- [11.HashMap put过程 + 扩容](#11.HashMap put过程 + 扩容)
-
- 1.先认识HashMap的核心成员变量
- [2.完整走一遍put过程(JDK 8)](#2.完整走一遍put过程(JDK 8))
- 3.resize扩容到底干了什么
- 12.HashMap为什么线程不安全
- 13.ConcurrentHashMap解决了什么问题
观前须知:本文是对java部分底层面试难点的剖析,笔者通过分析比对大量源码数据和查询了大量资料作出,写文不易,如果有帮助希望可以得到你的点赞。谢谢!
1.一次HTTP请求的完整流程
- 浏览器解析URL(统一资源定位符)
- DNS(Domain Name System域名系统)解析域名,得到域名对应的ip
- 建立TCP连接(三次握手)
- 客户端向服务端发送HTTP请求,因为HTTP请求只能在TCP上跑。HTTP本身是无状态的文本协议,即每次请求互相独立,服务端不知道你是谁,所以我们需要Cookie/Token等工具记住我们
- 服务端通过Tomcat接收请求,Spring Boot定分配任务、调取Controller的规则,Controller处理数据,返回Response(包含HTML、JSON、图片或视频等)
- 浏览器解析response并渲染页面
- 关闭或复用TCP连接
2.GET vs POST 区别
| 对比点 | GET | POST |
|---|---|---|
| 参数位置 | URL | 请求体 |
| 安全性 | 低(因为参数暴露在URL) | 相对高(参数在body里) |
| 长度限制 | 有 | 无 |
| 幂等性 | 是(多次请求,结果一致) | 否 |
| 使用场景 | 查询 | 提交 |
get设计原则:只获取资源,不修改资源
post设计原则:提交数据,产生"变化"
3.常见状态码
200:成功
400:请求参数错误
401:未登录
403:没权限
500:服务器内部错误
4.Cookie vs Session
Cookie是存储在浏览器中的小数据,用于携带Session ID;Session是服务器端保存的用户会话数据,通过Cookie中的ID定位,从而实现HTTP无状态下的用户身份识别
| Cookie | Session | |
|---|---|---|
| 存储位置 | 浏览器 | 服务器 |
| 安全性 | 低 | 高 |
| 生命周期 | 可配置 | 通常随会话关闭而结束 |
| 大小 | 小 | 大 |
5.前后端分离如何维护登录态
- 使用Token,如JWT(JSON Web Token)
- 登录成功后服务端返回Token
- 前端存储Token
- 每次请求在Header中携带
- 后端校验Token合法性
为什么不用Session
Session强依赖服务器状态 ,而前后端分离中存在多台服务器,负载均衡,且请求不一定落在哪一台的问题,我们当然可以使用Redis 实现Session共享,但是这样会存在架构复杂、运维成本高、状态管理麻烦的问题
| 对比点 | Session | Token |
|---|---|---|
| 是否有服务器状态 | 有 | 无 |
| 是否依赖Cookie | 是 | 否 |
| 是否适合分布式 | 一般 | 非常适合 |
| 跨端支持 | 差 | 好 |
| 扩展性 | 一般 | 强 |
Token的缺点
- 一旦泄露,风险大
- 谁拿到都能用
- 直到过期
解决:
- HTTPS
- 短有效期
- Refresh Token
- 无法"强制下线"
- Session可以直接删
- Token只能等过期
解决:
- 黑名单
- Token版本号
- Redis校验
- 体积比Cookie大
- 每次请求都带
- 增加流量
但是在现实中,体积大的问题完全可接受
为什么说Token是"登录态",而不是"权限"?
Token证明你是谁,而权限代表你能干什么
通常,Token里带userId,权限从数据库/缓存查,也就是说我们不把所有权限塞进Token
6.==和equals区别
==:比较地址
equals:比较内容
java
String a = new Stirng("abc");
String b = new String("abc");
a == b //false
a.equals(b) //true
7.为什么重写equals一定要重写hashCode
如果两个对象的equals相等,那么hashCode必须相等
原因:
-
HashMap的查找流程是先计算传入的key的hashCode值
-
用index = (n - 1) & hash; 将hash映射到数组某一个位置,即定位到"桶"
-
重点是在桶里找(关键)
javaif (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { return e; }此时HashMap开始遍历桶里的元素e,右侧hash代指我们查找的元素的hash值
第一重判断:e.hash == hash判断两个元素hashcode是否相等,若判断不一致,直接跳过,根本不会执行后续的equals方法,equals相当于白写
第二重判断:- (k = e.key) == key,直接根据两者内存地址判断是不是同一对象,如果判断成功直接认为相等,用不到后续的equals,实现性能优化,如果此时判断失败,则继续判断逻辑或后的表达式
- (key != null && key.equals(k)),这一步才是我们重写equals的地方。
举例说明:
示例类(错误写法)
java
class User {
int id;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
User user = (User) o;
return id == user.id;
}
// ❌ 没有重写 hashCode
}
使用HashMap
java
User u1 = new User(1);
User u2 = new User(1);
System.out.println(u1.equals(u2)); // true
Map<User, String> map = new HashMap<>();
map.put(u1, "张三");
System.out.println(map.get(u2)); // ❌ null
另外,当我们只使用equals方法时:
- this == o
u1 和 u2 不是同一个对象
返回 false - o instanceof User
u2 是 User
true - id == user.id
u1.id = 1
u2.id = 1
true
equals 返回 true
回到主题,为什么在上述例子中map.get(u2)返回null?(再次说明,若对上述例子理解可跳过)
这就是因为我们在User类中没有重写hashcode方法,我们用不严格的代码形式演示一下逻辑
java
map.put(u1, "张三");
//随后在put方法中,先获取u1 hash值,再定位桶
int hash1 = u1.hashCode()
index1 = (n - 1) & hash1
//在这之后,u1放进桶index1里
//get(u2)发生了什么?首先还是先得到hashCode,再定位桶
hash2 = u2.hashCode()
index2 = (n - 1) & hash2
//由于index1 == index2 的可能性较小(近似看作不可能发生),两者桶的位置都不一样,导致equals连执行的机会都没有,进而说明我们在重写equals必须重写hashcode
8.String为什么不可变
String被设计为不可变类,是通过final修饰类和内部字符数组,并且不提供修改内部数据的方法来实现的。不可变性带来了多方面好处:首先提高了安全性,其次天然支持线程安全,同时保证了作为HashMap等集合key时的稳定性,此外也使字符串常量池成为可能,从而提升内存和性能的效率。
-
源码一眼就能看出来
javapublic final class StringString这个类被final修饰,说明
- 不能被继承
- 防止子类破坏不可变性
-
真正存数据的地方
javaprivate final char[] value;- private:外部拿不到
- final:引用不能再指向别的数组
- chat[]:真正存字符的地方
-
为什么外部改不了内容
- 查看String源码可知,String没有提供任何修改value的方法
那么"拼接字符串是怎么回事"
示例
java
String s = "abc";
s = s + "def";
实际上
java
StringBuilder sb = new StringBuilder();
sb.append("abc");
sb.append("def");
String newStr = sb.toString();
可以看出,拼接字符串其实是创建了一个全新的String对象
为什么String必须不可变(重点)
-
Java的类加载、文件路径、网络地址、反射等都大量使用String,如果String可变,安全性直接爆炸
-
天然支持线程安全,因为String不可变、多线程共享、不需要锁
-
保证HashMap的稳定性
javaString key = "abc"; map.put(key, 1); // key 内容被改了 key = "def"; //如果String可变,key的hashcode也随之改变,原来value=1的值就永远得不到了,这不是我们想看到的
9.异常体系(Exception vs RuntimeException)
| Exception | RuntimeException | |
|---|---|---|
| 是否强制处理 | 是 | 否 |
| 使用场景 | 可预期异常 | 编程错误 |
Exception代表受检异常,是Java强制我们处理的异常
因为这些异常来自外部,没法保证一定不发生
比如:
- 文件不存在
- 网络断了
- 数据库连接失败
此时必须抛出异常
RuntimeException一般用于业务异常的处理,Java不强制我们处理
比如:
- 空指针
- 数组越界
- 参数非法
以上是程序员写代码的问题,不要用try-catch掩盖它
因此,当我们遇到RuntimeException,应该修改代码保证逻辑正确,而非抛出异常
什么是业务异常?
不是JVM错误,而是:
- 用户余额跟
- 用户未登录
- 商品库存不足
即逻辑不允许,不是系统崩了
为什么不建议捕获RuntimeException?
因为RuntimeException通常表示程序逻辑错误,捕获后可能掩盖bug,正确做法是修复代码,而不是try-catch
10.ArrayList扩容机制
ArrayList底层是基于数组实现的,当添加元素导致容量不足时会触发扩容。扩容时会创建一个新的数组,新容量通常为原容量的1.5倍,即oldCapacity + (oldCapacity >> 1),然后通过数组拷贝将原有数据复制到新数组中。由于扩容涉及数组复制,时间复杂度为O(n),因此在已知元素数量的情况下,建议提前指定容量以减少扩容带来的性能开销
以下为详解
1.ArrayList的底层结构
java
transient Object[] elementData;
看得出来,ArrayList真正存数据的就是一个Obejct[],优点是下标访问快,缺点是数组长度固定,也就是说,其实ArrayList存数据的数组本身并不能扩容
2.什么时候触发扩容
当我们调用:
java
list.add(e);
最终会走到(简化):
java
ensureCapacityInternal(size + 1);
意思是:
我要放第size + 1 个元素了,问一下容量够不够
3.关键入口代码
java
private void ensureCapacityInternal(int minCapacity) {
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
说白了:
如果【需要的容量】 > 【当前数组长度】,那就扩容
4.核心扩容逻辑
java
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1); // 1.5 倍
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
elementData = Arrays.copyOf(elementData, newCapacity);
}
oldCapacity >> 1是位运算,即二进制形态右移一位,所以oldCapacity >> 1 == oldCapacity / 2,即newCapacity = oldCapacity + oldCapacity / 2;也就是1.5倍扩容
5.为什么是1.5倍?
JDK团队实践出来得出的经验值
如果扩2倍
扩容次数虽然少,但是内存浪费严重
如果扩一点点
内存利用率虽然高,但扩容太过于频繁,复制次数爆炸,极度浪费性能
5.创建新数组的机制
java
elementData = Arrays.copyOf(elementData, newCapacity);//底层实际是System.arraycopy(...)
这是一次完整的数组拷贝,时间复杂度为O(n),要复制的元素越多,运行越慢
7.为什么"建议提前指定容量"?
好处:
- 一次性分配足够数组
- 避免多次扩容+拷贝
- 性能稳定
适合:
- 批量导入
- 查库后装集合
- 已知数据量的场景
8.一个容易被忽略的细节
默认构造方法:
java
new ArrayList<>();
也就是说,一开始不会创建长度为10的数组
当第一次add时,才会创建一个容量为10的数组
源码里叫:
java
DEFAULT_CAPACITY = 10;
11.HashMap put过程 + 扩容
HashMap的put过程首先会对key进行hash运算并定位数组下标,如果桶为空则直接插入;如果发生哈希冲突,则通过equals在链表或红黑树中查找,存在相同key值则value覆盖,不存在则在尾部插入新节点,当插入完成后size自增,如果size超过阈值(即threshold),则触发扩容。扩容时会创建一个容量为原来2倍的新数组,并通过位运算将原有节点重新分布到新数组中
以下为详解
1.先认识HashMap的核心成员变量
java
transient Node<K,V>[] table; // 数组
int size; // 实际元素个数
int threshold; // 扩容阈值
final float loadFactor; // 负载因子,默认 0.75
threshold是什么
java
threshold = capacity * loadFactor;
默认:
- capacity = 16
- loadFactor = 0.75
- threshold = 12
当size > 12时扩容
2.完整走一遍put过程(JDK 8)
入口方法:
java
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
(1)计算hash(不是直接hashCode)
java
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
高16位参与运算,减少冲突
(2)初始化数组(第一次put)
java
if (table == null || table.length == 0)
table = resize();
默认创建长度为16的数组
(3)定位桶下标
java
int i = (n - 1) & hash;
位运算,前面有讲
(4)桶为空,直接插入(理想情况)
java
if (table[i] == null)
table[i] = newNode(hash, key, value, null);
(5)桶不为空,发生冲突
情况一:key已存在(覆盖)
java
if (p.hash == hash && key.equals(p.key)) {
p.value = value;
}
情况二:链表(或红黑树)中查找(这一块较难理解,看注释)
java
//下方为一个死循环,结束条件是找到链表的尾部或找到key值相同的节点
for (int binCount = 0; ; ++binCount) {
//查询是否到达链表尾部
if ((e = p.next) == null) {
//到达链表尾部,将新节点接到尾部,退出循环
p.next = newNode(hash, key, value, null);
break;
}
//查询是否找到key值相同的节点
if (e.hash == hash && key.equals(e.key))
//找到key值相同的节点,退出循环,后续统一进行value()覆盖
break;
//某一轮循环结束并没有退出,则让指针往后挪一位,继续往链表深处走
p = e;
}
总结:当HashMap在put时发生哈希冲突,会在对应桶内以链表或红黑树的形式查找节点。源码中通过遍历链表,先判断是否存在相同的key,存在则覆盖value,不存在则在链表的尾部插入新节点,同时统计节点数量以决定是否树化
(6)链表过长,树化
java
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(table, hash);
树化的条件如下:
- 链表阈值:8
- 数组长度 >= 64
- 特殊:如果此时数组长度为16,链表长度为8;数组会扩容成32,并且原先8长度的链表可能会不存在,如果扩容后依然存在8长度以上的链表,数组会继续扩容到64;此时再次检测有没有超过8长度的链表,如果有,则直接树化
(7)size++并判断是否扩容
java
if (++size > threshold)
resize();
3.resize扩容到底干了什么
(1)新数组容量
java
newCap = oldCap << 1; // 扩容为 2 倍
注意!HashMap扩容是2倍,ArrayList扩容是1.5倍
(2)新阈值
java
newThreshold = newCap * loadFactor;
比如原容量是16,扩容后新容量为32,新阈值就变成了24
(3)元素迁移
java
(e.hash & oldCap) == 0
位与运算,当数组长度为2的幂时,该运算才可正常进行,这就是数组长度必须是2的幂的原因
此外,当上式成立时,该元素的位置不变,继续留在原来桶的区域;如果不成立,则意味着该元素需要迁移到新数组的"另一半"
12.HashMap为什么线程不安全
根本原因在于它的设计本身没有任何同步控制,以及在多线程环境下,特别是当多个线程同时进行扩容(resize)操作时,可能会引发一系列问题,如数据覆盖、结构混乱、死循环等
13.ConcurrentHashMap解决了什么问题
ConcurrentHashMap是java提供的线程安全的哈希表实现,它解决了多线程环境下的并发访问问题,避免了全表锁,相比HashMap,提供了更高效的并发支持。
1.线程安全
ConcurrentHashMap通过锁分段(在JDK7中)和CAS + synchronized(JDK8)等技术保证了它在多线程环境中的线程安全性。这意味着多个线程可以同时安全地执行put、get等操作,而不会出现数据不一致的问题
2.提高并发性能
传统的线程安全哈希表(如Hashtabke)需要对整个表加锁,即在任意一个线程操作哈希表时,其他线程必须等待,导致并发性能较低。而ConcurrentHashMap的设计允许多个线程并发地操作不同的桶(bucket)中的数据,减少了线程等待的时间,提高了并发性能
分段锁机制的工作原理:
- ConcurrentHashMap会将底层的数据分成多个段(Segment[] 数组),每个段是一个独立的哈希表
- 每个段都有一个锁,操作数据时,需要先锁定对应的段。不同段之间可以并发访问,即使有多个线程同时对不同段进行操作,它们也不会相互阻塞
- 当需要访问数据时,首先计算哈希值,通过分段锁来定位要访问的段
- 每个段内部的操作依然是线程安全的,可以使用锁来保证
这种方式的好处是:避免了全表锁,每个段都有独立的锁,因此多个线程可以并行操作不同的段。
但是,分段锁的粒度较粗,可能会导致锁竞争,因此在JDK8中,ConcurrentHashMap对其进行了优化
JDK8: CAS + synchronized:
通过使用CAS(Compare-And-Swap)和synchronized结合的方式进一步优化性能,其实现特点如下:
- CAS:用于更新数据时避免锁竞争。通过CAS操作,ConcurrentHashMap可以实现无锁的并发操作。例如,在对一个桶的链表进行修改时,如果当前节点没有被其他线程修改,那么可以直接更新它,这样可以减少对锁的依赖。
- synchronized:当某个操作无法通过CAS实现时,ConcurrentHashMap会使用synchronized来确保该操作是线程安全的。比如在插入元素时,如果需要修改桶内的链表或树时,synchronized可以保证操作的原子性
实现原理:
- ConcurrentHashMap在底层使用了一个数组和多个链表/红黑树,每个桶的元素会根据哈希值存储在相应的位置
- 当多个线程同时访问同一个桶时,ConcurrentHashMap使用CAS处理常规的读写操作
- 如果CAS无法保证线程安全(比如在高并发下可能发生竞争),则会通过synchronized锁住特定的桶
- 通过细粒度锁(锁定桶或链表级别),并发性能得到了显著提升