2025.12.21 学习web前必要知识点梳理

文章目录

观前须知:本文是对java部分底层面试难点的剖析,笔者通过分析比对大量源码数据和查询了大量资料作出,写文不易,如果有帮助希望可以得到你的点赞。谢谢!

1.一次HTTP请求的完整流程

  1. 浏览器解析URL(统一资源定位符)
  2. DNS(Domain Name System域名系统)解析域名,得到域名对应的ip
  3. 建立TCP连接(三次握手)
  4. 客户端向服务端发送HTTP请求,因为HTTP请求只能在TCP上跑。HTTP本身是无状态的文本协议,即每次请求互相独立,服务端不知道你是谁,所以我们需要Cookie/Token等工具记住我们
  5. 服务端通过Tomcat接收请求,Spring Boot定分配任务、调取Controller的规则,Controller处理数据,返回Response(包含HTML、JSON、图片或视频等)
  6. 浏览器解析response并渲染页面
  7. 关闭或复用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.前后端分离如何维护登录态

  1. 使用Token,如JWT(JSON Web Token)
  2. 登录成功后服务端返回Token
  3. 前端存储Token
  4. 每次请求在Header中携带
  5. 后端校验Token合法性

为什么不用Session

Session强依赖服务器状态 ,而前后端分离中存在多台服务器,负载均衡,且请求不一定落在哪一台的问题,我们当然可以使用Redis 实现Session共享,但是这样会存在架构复杂、运维成本高、状态管理麻烦的问题

对比点 Session Token
是否有服务器状态
是否依赖Cookie
是否适合分布式 一般 非常适合
跨端支持
扩展性 一般

Token的缺点

  1. 一旦泄露,风险大
  • 谁拿到都能用
  • 直到过期

解决:

  • HTTPS
  • 短有效期
  • Refresh Token
  1. 无法"强制下线"
  • Session可以直接删
  • Token只能等过期

解决:

  • 黑名单
  • Token版本号
  • Redis校验
  1. 体积比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必须相等

原因:

  1. HashMap的查找流程是先计算传入的key的hashCode值

  2. 用index = (n - 1) & hash; 将hash映射到数组某一个位置,即定位到"桶"

  3. 重点是在桶里找(关键)

    java 复制代码
    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相当于白写
    第二重判断:

    1. (k = e.key) == key,直接根据两者内存地址判断是不是同一对象,如果判断成功直接认为相等,用不到后续的equals,实现性能优化,如果此时判断失败,则继续判断逻辑或后的表达式
    2. (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方法时:

  1. this == o
    u1 和 u2 不是同一个对象
    返回 false
  2. o instanceof User
    u2 是 User
    true
  3. 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时的稳定性,此外也使字符串常量池成为可能,从而提升内存和性能的效率。

  1. 源码一眼就能看出来

    java 复制代码
    public final class String

    String这个类被final修饰,说明

    • 不能被继承
    • 防止子类破坏不可变性
  2. 真正存数据的地方

    java 复制代码
    private final char[] value;
    • private:外部拿不到
    • final:引用不能再指向别的数组
    • chat[]:真正存字符的地方
  3. 为什么外部改不了内容

    • 查看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必须不可变(重点)

  1. Java的类加载、文件路径、网络地址、反射等都大量使用String,如果String可变,安全性直接爆炸

  2. 天然支持线程安全,因为String不可变、多线程共享、不需要锁

  3. 保证HashMap的稳定性

    java 复制代码
    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的底层结构

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锁住特定的桶
  • 通过细粒度锁(锁定桶或链表级别),并发性能得到了显著提升
相关推荐
xrkhy1 天前
多线程,高并发、物联网以及spring架构的面试题-->周
java·spring·架构
a程序小傲1 天前
中国邮政Java面试被问:gRPC的HTTP/2流控制和消息分帧
java·开发语言·后端
forestsea1 天前
Springboot 4.0十字路口:虚拟线程时代,WebFlux与WebMVC的终极选择
java·后端·spring
Sylvia-girl1 天前
Java之构造方法
java·开发语言
予枫的编程笔记1 天前
深度解析Kibana:从基础到进阶的全维度数据可视化指南
java·人工智能·elasticsearch·kibana
galaxyffang1 天前
漏桶、令牌桶与滑动窗口业务场景选型
java
moxiaoran57531 天前
使用策略模式+装饰器模式实现接口防重复提交
java·装饰器模式
AM越.1 天前
Java设计模式超详解--代理设计模式(含uml图)
java·设计模式·uml
tkevinjd1 天前
JUC1(多线程的三种实现方式)
java·多线程·juc