2025.12.21 学习web前必要知识点梳理
文章目录
观前须知:本文是对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:比较内容
String a =newStirng("abc");String b =newString("abc"); a == b //false a.equals(b)//true7.为什么重写equals一定要重写hashCode
如果两个对象的equals相等,那么hashCode必须相等
原因:
- HashMap的查找流程是先计算传入的key的hashCode值
- 用index = (n - 1) & hash; 将hash映射到数组某一个位置,即定位到“桶”
- (k = e.key) == key,直接根据两者内存地址判断是不是同一对象,如果判断成功直接认为相等,用不到后续的equals,实现性能优化,如果此时判断失败,则继续判断逻辑或后的表达式
- (key != null && key.equals(k)),这一步才是我们重写equals的地方。
重点是在桶里找(关键)
if(e.hash == hash &&((k = e.key)== key ||(key !=null&& key.equals(k)))){return e;}此时HashMap开始遍历桶里的元素e,右侧hash代指我们查找的元素的hash值
第一重判断:e.hash == hash判断两个元素hashcode是否相等,若判断不一致,直接跳过,根本不会执行后续的equals方法,equals相当于白写
第二重判断:
举例说明:
示例类(错误写法)
classUser{int id;@Overridepublicbooleanequals(Object o){if(this== o)returntrue;if(!(o instanceofUser))returnfalse;User user =(User) o;return id == user.id;}// ❌ 没有重写 hashCode}使用HashMap
User u1 =newUser(1);User u2 =newUser(1);System.out.println(u1.equals(u2));// trueMap<User,String> map =newHashMap<>(); 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方法,我们用不严格的代码形式演示一下逻辑
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必须重写hashcode8.String为什么不可变
String被设计为不可变类,是通过final修饰类和内部字符数组,并且不提供修改内部数据的方法来实现的。不可变性带来了多方面好处:首先提高了安全性,其次天然支持线程安全,同时保证了作为HashMap等集合key时的稳定性,此外也使字符串常量池成为可能,从而提升内存和性能的效率。
- 不能被继承
- 防止子类破坏不可变性
- private:外部拿不到
- final:引用不能再指向别的数组
- chat[]:真正存字符的地方
- 为什么外部改不了内容
- 查看String源码可知,String没有提供任何修改value的方法
真正存数据的地方
privatefinalchar[] value;源码一眼就能看出来
publicfinalclassStringString这个类被final修饰,说明
那么“拼接字符串是怎么回事”
示例
String s ="abc"; s = s +"def";实际上
StringBuilder sb =newStringBuilder(); sb.append("abc"); sb.append("def");String newStr = sb.toString();可以看出,拼接字符串其实是创建了一个全新的String对象
为什么String必须不可变(重点)
- Java的类加载、文件路径、网络地址、反射等都大量使用String,如果String可变,安全性直接爆炸
- 天然支持线程安全,因为String不可变、多线程共享、不需要锁
保证HashMap的稳定性
String 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的底层结构
transientObject[] elementData;看得出来,ArrayList真正存数据的就是一个Obejct[],优点是下标访问快,缺点是数组长度固定,也就是说,其实ArrayList存数据的数组本身并不能扩容
2.什么时候触发扩容
当我们调用:
list.add(e);最终会走到(简化):
ensureCapacityInternal(size +1);意思是:
我要放第size + 1 个元素了,问一下容量够不够
3.关键入口代码
privatevoidensureCapacityInternal(int minCapacity){if(minCapacity - elementData.length >0)grow(minCapacity);}说白了:
如果【需要的容量】 > 【当前数组长度】,那就扩容
4.核心扩容逻辑
privatevoidgrow(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.创建新数组的机制
elementData =Arrays.copyOf(elementData, newCapacity);//底层实际是System.arraycopy(...)这是一次完整的数组拷贝,时间复杂度为O(n),要复制的元素越多,运行越慢
7.为什么“建议提前指定容量”?
好处:
- 一次性分配足够数组
- 避免多次扩容+拷贝
- 性能稳定
适合:
- 批量导入
- 查库后装集合
- 已知数据量的场景
8.一个容易被忽略的细节
默认构造方法:
newArrayList<>();也就是说,一开始不会创建长度为10的数组
当第一次add时,才会创建一个容量为10的数组
源码里叫:
DEFAULT_CAPACITY =10;11.HashMap put过程 + 扩容
HashMap的put过程首先会对key进行hash运算并定位数组下标,如果桶为空则直接插入;如果发生哈希冲突,则通过equals在链表或红黑树中查找,存在相同key值则value覆盖,不存在则在尾部插入新节点,当插入完成后size自增,如果size超过阈值(即threshold),则触发扩容。扩容时会创建一个容量为原来2倍的新数组,并通过位运算将原有节点重新分布到新数组中
以下为详解
1.先认识HashMap的核心成员变量
transientNode<K,V>[] table;// 数组int size;// 实际元素个数int threshold;// 扩容阈值finalfloat loadFactor;// 负载因子,默认 0.75threshold是什么
threshold = capacity * loadFactor;默认:
- capacity = 16
- loadFactor = 0.75
- threshold = 12
当size > 12时扩容
2.完整走一遍put过程(JDK 8)
入口方法:
publicVput(K key,V value){returnputVal(hash(key), key, value,false,true);}(1)计算hash(不是直接hashCode)
staticfinalinthash(Object key){int h;return(key ==null)?0:(h = key.hashCode())^(h >>>16);}高16位参与运算,减少冲突
(2)初始化数组(第一次put)
if(table ==null|| table.length ==0) table =resize();默认创建长度为16的数组
(3)定位桶下标
int i =(n -1)& hash;位运算,前面有讲
(4)桶为空,直接插入(理想情况)
if(table[i]==null) table[i]=newNode(hash, key, value,null);(5)桶不为空,发生冲突
情况一:key已存在(覆盖)
if(p.hash == hash && key.equals(p.key)){ p.value = value;}情况二:链表(或红黑树)中查找(这一块较难理解,看注释)
//下方为一个死循环,结束条件是找到链表的尾部或找到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)链表过长,树化
if(binCount >= TREEIFY_THRESHOLD -1)treeifyBin(table, hash);树化的条件如下:
- 链表阈值:8
- 数组长度 >= 64
- 特殊:如果此时数组长度为16,链表长度为8;数组会扩容成32,并且原先8长度的链表可能会不存在,如果扩容后依然存在8长度以上的链表,数组会继续扩容到64;此时再次检测有没有超过8长度的链表,如果有,则直接树化
(7)size++并判断是否扩容
if(++size > threshold)resize();3.resize扩容到底干了什么
(1)新数组容量
newCap = oldCap <<1;// 扩容为 2 倍注意!HashMap扩容是2倍,ArrayList扩容是1.5倍
(2)新阈值
newThreshold = newCap * loadFactor;比如原容量是16,扩容后新容量为32,新阈值就变成了24
(3)元素迁移
(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锁住特定的桶
- 通过细粒度锁(锁定桶或链表级别),并发性能得到了显著提升