慢慢攻略HashMap(3)——put方法篇

本文深入剖析了HashMap的put方法,讲解了hash函数如何计算键的路由寻址并保持高位信息,同时揭示了putVal方法中扩容、替换及节点操作的细节。

********本篇主要介绍了HashMap源码中put方法部分********


下面展示关于put方法HashMap底层源码

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

由代码可见:实际上 put 只是套娃了一个 putVal 方法

所以我们要去看一下putVal方法,从putVal方法的参数可以看出,里面包括了一个hash方法,参数为key:

hash(key)

这个方法是干什么的呢?我们来一探究竟。
首先来看看hash(key)的源码

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

这段代码称 扰动函数
作用:让key的hash值的高16位也参与路由寻址运算

举个🌰:
假设 传进来的key的hashcode值赋给h后,h = 0b 0010 0101 1010 1100 0011 1111 0010 1110,根据源码进行^异或运算:

0b 0010 0101 1010 1100 0011 1111 0010 1110 (h)
^
0b 0000 0000 0000 0000 0010 0101 1010 1100 (h >>> 16)

=> 0010 0101 1010 1100 0001 1010 1000 0010

以上运算是为了让高16位和低16位做运算
也算是变相地保留了高位的信息,让高16位也参与路由寻址。
小疑问:h >>> 16是干什么
答:如果不进行右移运算,没法让高16位与低16位进行运算。

看完了hash方法,可以正式的看一下putVal方法了:每行的注释我已经加进去了…

/**
     * Implements Map.put and related methods
     *
     * @param hash hash for key key的hash值
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent if true, don't change existing value 如果散列表当中某一个key和你插入的key是一样的就不插了 
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    //tab:引用当前hashmap的散列表
    //p:表示当前散列表的元素
    //n:表示散列表数组的长度
    //i:表示路由寻址的结果
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    
    //1号🌰延迟初始化逻辑,第一次调用putVal时会初始化hashMap对象中的最耗费内存的散列表
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //2号🌰最简单的一种情况:寻址都找到的桶位放好是null,这个时候,应该将当前k-v=>ode 扔进去就行了
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    
    else {
        //e:不为null的话,找到了一个与当前要插入的key-value一致的key元素
        //k:表示临时的一个key
        Node<K,V> e; K k;
        
        //传进来的hash值等于已经存在p的哈希值 并且传进来的key也和p的key一样 
        //也就表示当前桶位中的元素,与你当前插入的元素的key完全一致,表示后续需要进行替换操作
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        
        //p已经树化了
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            //链表的情况,并且链表的头元素与我们要插入的key不一致 我们得遍历链表了
            for (int binCount = 0; ; ++binCount) {
                
                //条件成立的话,说明迭代到最后一个元素了,也没找到一个与你要插入的key一致的node
                //说明允许加入到当前链表的末尾
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    //如果链表长度大于8 就得进行树化操作了
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                //条件成立的话,说明找到相同key的node元素,break出去 然后进行替换操作
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        //替换操作 e!=null 说明找到了与你插入数据一致的元素,把老value输出,把老值换成新值
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    //表示散列表结构被修改次数,替换node元素的value不计数
    ++modCount;
    //插入新元素,size自增,如果自增后的值大于扩容阈值就扩容
    if (++size > threshold)
        //3号🌰扩容方法
        resize();
    afterNodeInsertion(evict);
    return null;
}

以上标记🌰的位置是重点:

X位置重点
1号🌰延迟初始化
2号🌰(n - 1) & hash 求下标方法
3号🌰resize() 扩容方法

1号🌰

if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;

如果table为null,那么就初始化table散列表,resize中有扩容方法。

2号🌰

if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);

如果通过下标运算定位的位置是null,那就直接把key-value扔到该位置就行了。
(n - 1) & hash:下标运算。

补充——核心知识点:为什么 table 的长度 一定是2的幂

计算下标得算法,实际就是取模,hash%length

计算机中直接求余效率不如位移运算,源码中做了优化hash&(length-1)

要想保证hash%length==hash&(length-1)

那么length必须是2的n次方

3号🌰

if (++size > threshold)
        resize();

size大于负载因子时进行扩容,扩容方法下期讲~~~~

本博客仅供学习参考,也是个人笔记总结,如果错误请见谅~~

```markdown > **普通文本型回答** 太好了!你现在的目标非常明确:**通过面试**。 那我们就聚焦在“面试官想听什么”、“常考知识点有哪些”、“代码如何解释才专业”, 并以最清晰、最系统的方式,**逐行详解 `TodoServer.java` 服务端代码**。 --- ## 🧩 完整服务端代码(带注释版) 我们先看完整代码,然后分段详细讲解每一行的含义和背后的原理。 ```java import java.io.*; import java.net.*; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; ``` ### 🔍 第1部分:导入包(Import Statements) | 行 | 代码 | 含义 | |----|------|------| | 1 | `import java.io.*;` | 引入输入输出相关类(如 `InputStream`, `OutputStream`, `BufferedReader`, `PrintWriter`)——用于读写网络数据 | | 2 | `import java.net.*;` | 引入网络编程类(如 `Socket`, `ServerSocket`)——实现客户端-服务器通信 | | 3 | `import java.util.*;` | 引入常用工具类(如 `Map`, `HashMap`, `Scanner` 等) | | 4 | `import java.util.concurrent.ConcurrentHashMap;` | 线程安全的 Map,防止多线程并发访问出错 | | 5 | `import java.util.concurrent.atomic.AtomicInteger;` | 原子整数,保证 ID 自增不冲突 | 📌 **面试重点提示**: > - 多个客户端同时连接时,会触发**多线程并发问题** > - 所以不能用普通的 `HashMap` 和 `int id = 1; id++` > - 必须使用 `ConcurrentHashMap` 和 `AtomicInteger` 来保证线程安全 --- ```java public class TodoServer { private static final Map<Integer, String> tasks = new ConcurrentHashMap<>(); private static final AtomicInteger nextId = new AtomicInteger(1); ``` ### 🔍 第2部分:类定义与成员变量 #### ✅ `public class TodoServer {` - 定义一个公开类,名字叫 `TodoServer` - Java 文件名必须与类名一致 → 文件名为 `TodoServer.java` #### ✅ `private static final Map<Integer, String> tasks = new ConcurrentHashMap<>();` - `Map<Integer, String>`:键值对结构,key 是任务ID(整数),value 是任务内容(字符串) - `ConcurrentHashMap`:线程安全版本的 HashMap,适合多客户端并发操作 - `static`:属于类本身,所有线程共享同一份数据 - `final`:初始化后不可重新赋值(但内容可变) 📌 面试回答示例: > “我使用了 `ConcurrentHashMap` 而不是 `HashMap`,因为在多线程环境下,多个客户端可能同时添加或删除任务,`HashMap` 不是线程安全的,会导致数据错乱甚至程序崩溃。” #### ✅ `private static final AtomicInteger nextId = new AtomicInteger(1);` - `AtomicInteger`:提供原子性自增操作 - `nextId.getAndIncrement()`:获取当前值并 +1,整个过程不可中断 - 替代 `int id = 1; id++`(后者不是原子操作) 📌 举例说明: > 如果两个客户端几乎同时请求添加任务,普通 `id++` 可能导致两个任务拿到相同 ID。而 `AtomicInteger` 能确保每个任务获得唯一递增 ID。 --- ```java private static String processCommand(String command) { if (command == null || command.trim().isEmpty()) { return "错误:命令不能为空"; } String[] parts = command.trim().split("\\s+", 2); String cmd = parts[0].toLowerCase(); ``` ### 🔍 第3部分:命令处理函数 —— `processCommand` #### ✅ 方法签名 ```java private static String processCommand(String command) ``` - `private`:仅供内部调用 - `static`:无需创建对象即可调用 - 返回类型 `String`:返回给客户端的响应消息 #### ✅ 空值检查 ```java if (command == null || command.trim().isEmpty()) ``` - `null`:客户端断开等异常情况 - `.trim()`:去除前后空格 - `.isEmpty()`:判断是否为空字符串 👉 返回友好错误提示,避免程序抛出异常。 #### ✅ 分割命令 ```java String[] parts = command.trim().split("\\s+", 2); ``` - `\s+`:正则表达式,表示“一个或多个空白字符”(空格、制表符等) - `2`:最多切两块,例如: - 输入:`add 买牛奶 bread` - 结果:`parts[0] = "add"`, `parts[1] = "买牛奶 bread"` - 这样任务名中的空格不会被误拆 📌 面试加分点: > “我使用 `split("\\s+", 2)` 而不是 `split(" ")`,是为了支持任务名称中包含空格的情况,并且只分割第一个空格作为命令与参数的界限。” #### ✅ 提取命令动词 ```java String cmd = parts[0].toLowerCase(); ``` - 统一转小写,支持用户输入 `ADD`、`Add`、`aDd` 等各种大小写形式 --- ```java switch (cmd) { case "add": if (parts.length < 2 || parts[1].trim().isEmpty()) { return "错误:add 后需跟任务内容"; } int id = nextId.getAndIncrement(); tasks.put(id, parts[1].trim()); return "任务已添加,ID: " + id; ``` ### 🔍 第4部分:`add` 命令处理 #### ✅ `switch (cmd)` 结构 - 比 `if-else` 更高效、更清晰地处理多种命令 - 支持 `add`, `list`, `modify`, `delete`, `help`, `quit` 等 #### ✅ 参数校验 ```java if (parts.length < 2 || parts[1].trim().isEmpty()) ``` - `parts.length < 2`:没有参数,如只输入 `add` - `parts[1].trim().isEmpty()`:参数全是空格,如 `add ` 👉 返回明确错误信息,提升用户体验。 #### ✅ 添加任务 ```java int id = nextId.getAndIncrement(); tasks.put(id, parts[1].trim()); ``` - 获取唯一 ID - 存入线程安全的 Map - 返回成功提示 📌 注意: > 不要写 `tasks.size() + 1` 作为 ID,因为并发下可能重复! --- ```java case "list": if (tasks.isEmpty()) { return "暂无任务"; } StringBuilder sb = new StringBuilder("当前任务列表:\n"); for (Map.Entry<Integer, String> entry : tasks.entrySet()) { sb.append(entry.getKey()).append(". ").append(entry.getValue()).append("\n"); } return sb.toString().trim(); ``` ### 🔍 第5部分:`list` 命令处理 #### ✅ 判断是否有任务 ```java if (tasks.isEmpty()) ``` - `isEmpty()` 比 `size() == 0` 更直观、性能更好 #### ✅ 使用 `StringBuilder` ```java StringBuilder sb = new StringBuilder(); sb.append("...").append(id).append(". ").append(task).append("\n"); ``` - 字符串拼接推荐用 `StringBuilder`,而不是 `+` - 因为 `+` 在循环中会产生大量临时对象,影响性能 #### ✅ 格式化输出 - 每行格式:`ID. 任务内容` - 最后 `.trim()` 去掉末尾多余换行 📌 示例输出: ``` 当前任务列表: 1. 学习Java 2. 写作业 ``` --- ```java case "modify": if (parts.length < 2) { return "错误:modify 需要 ID 和新任务内容"; } String[] modArgs = parts[1].split("\\s+", 2); if (modArgs.length < 2) { return "错误:modify 格式应为 modify <ID> <新任务>"; } try { int modId = Integer.parseInt(modArgs[0]); if (!tasks.containsKey(modId)) { return "错误:ID " + modId + " 不存在"; } tasks.put(modId, modArgs[1]); return "任务 " + modId + " 已更新为 \"" + modArgs[1] + "\""; } catch (NumberFormatException e) { return "错误:ID 必须是数字"; } ``` ### 🔍 第6部分:`modify` 修改任务 #### ✅ 二次分割 ```java String[] modArgs = parts[1].split("\\s+", 2); ``` - 第一次分割得到 `"modify 1 新任务"` → `parts[1] = "1 新任务"` - 第二次分割:`modArgs[0] = "1"`, `modArgs[1] = "新任务"` #### ✅ 类型转换与异常处理 ```java int modId = Integer.parseInt(modArgs[0]); ``` - 将字符串转成整数 - 如果不是数字(如 `modify abc ...`),会抛出 `NumberFormatException` 👉 使用 `try-catch` 捕获异常,返回友好提示,而不是让服务器崩溃。 #### ✅ 存在性检查 ```java if (!tasks.containsKey(modId)) ``` - 先查是否存在该 ID,避免覆盖未知数据 --- ```java case "delete": if (parts.length < 2) { return "错误:delete 需要指定 ID"; } try { int delId = Integer.parseInt(parts[1]); if (tasks.remove(delId) == null) { return "错误:ID " + delId + " 不存在"; } return "任务 " + delId + " 已删除"; } catch (NumberFormatException e) { return "错误:ID 必须是数字"; } ``` ### 🔍 第7部分:`delete` 删除任务 #### ✅ `tasks.remove(key)` - 成功删除 → 返回被删除的值 - 不存在 → 返回 `null` - 所以可以用 `== null` 判断是否删除成功 --- ```java case "help": return """ 可用命令: add <任务> — 添加任务 list — 查看所有任务 modify <ID> <新任务> — 修改指定任务 delete <ID> — 删除任务 help — 显示此帮助 quit/exit — 断开连接"""; ``` ### 🔍 第8部分:`help` 帮助命令 - 使用三重引号 `"""..."""`(Java 15+)定义多行字符串 - 格式清晰,便于阅读 - 若使用旧版 Java,可用 `\n` 拼接 --- ```java default: return "未知命令:" + cmd + "。输入 help 查看帮助。"; } } ``` ### 🔍 默认分支 - 处理所有不识别的命令,如 `hi`, `abc` 等 - 返回提示,引导用户输入 `help` --- ```java public static void main(String[] args) { System.out.println("TodoList 服务器启动中,监听端口 " + PORT + " ..."); try (ServerSocket serverSocket = new ServerSocket(PORT)) { while (true) { Socket client = serverSocket.accept(); System.out.println("客户端已连接: " + client.getInetAddress()); new Thread(() -> handleClient(client)).start(); } } catch (IOException e) { System.err.println("服务器启动失败: " + e.getMessage()); } } ``` ### 🔍 第9部分:主函数 `main` —— 服务器启动入口 #### ✅ `ServerSocket serverSocket = new ServerSocket(PORT)` - 监听指定端口(如 8080) - 等待客户端连接 #### ✅ `serverSocket.accept()` - 阻塞方法:一直等待,直到有客户端连接 - 一旦连接成功,返回一个 `Socket` 对象,代表与该客户的通信链路 #### ✅ 多线程处理 ```java new Thread(() -> handleClient(client)).start(); ``` - 每来一个客户端,就新开一个线程处理 - 实现**并发服务多个客户端** - Lambda 表达式写法简洁 📌 面试高频问题: > Q:为什么要用多线程? > A:因为 `accept()` 和 `readLine()` 都是阻塞操作,如果不开启新线程,第二个客户端就必须等第一个断开才能连接,无法并发。 --- ```java private static void handleClient(Socket socket) { try ( BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); PrintWriter out = new PrintWriter(new OutputStreamWriter(socket.getOutputStream()), true) ) { out.println("✅ 欢迎使用 TodoList 服务!"); out.println("📌 输入 help 获取帮助"); String input; while ((input = in.readLine()) != null) { input = input.trim(); System.out.println("来自客户端 [" + socket.getInetAddress() + "] 的命令: " + input); if ("quit".equalsIgnoreCase(input) || "exit".equalsIgnoreCase(input)) { out.println("👋 再见!连接已关闭"); break; } String response = processCommand(input); out.println(response); } } catch (IOException e) { System.err.println("客户端连接中断: " + e.getMessage()); } finally { try { socket.close(); } catch (IOException ignored) {} } } } ``` ### 🔍 第10部分:`handleClient` —— 处理单个客户端 #### ✅ try-with-resources ```java try (BufferedReader in = ...; PrintWriter out = ...) { ``` - 自动关闭资源,无需手动 `close()` - 即使发生异常也能保证流被释放 - 是现代 Java 推荐写法 #### ✅ 输入输出流初始化 - `socket.getInputStream()` → 得到字节流 - `InputStreamReader` → 转为字符流 - `BufferedReader` → 支持按行读取 - 输出同理,最终包装成 `PrintWriter`,支持自动刷新 #### ✅ 主循环逻辑 1. 读取客户端命令 2. 忽略空命令 3. 判断是否退出 4. 调用 `processCommand` 处理 5. 发送响应 #### ✅ 日志打印 ```java System.out.println("来自客户端 [...] 的命令: " + input); ``` - 方便调试,查看谁发了什么命令 #### ✅ finally 关闭连接 - 确保即使出错也会关闭 `socket`,防止资源泄漏 --- ## 🎯 面试常见问题 & 回答模板 | 问题 | 回答要点 | |------|----------| | **Q:你怎么保证多客户端并发安全?** | 使用 `ConcurrentHashMap` 和 `AtomicInteger`,避免 `HashMap` 和 `id++` 的线程安全问题 | | **Q:为什么要开线程?** | 因为 IO 是阻塞的,不开线程会导致只能服务一个客户 | | **Q:`split("\\s+", 2)` 是什么意思?** | 按空白字符分割,最多两部分,保留任务名中的空格 | | **Q:为什么用 `StringBuilder`?** | 高效拼接字符串,避免创建过多临时对象 | | **Q:怎么防止空指针?** | 检查 `command` 是否为空,`.trim()` 前判断非空 | | **Q:如果客户端断电了怎么办?** | `readLine()` 返回 `null`,循环结束,线程自动退出,资源由 finally 关闭 | --- ## ✅ 总结:服务端核心知识点(面试必背) | 知识点 | 解释(≤50字) | |--------|----------------| | **ServerSocket 与 Socket** | 服务端用 `ServerSocket` 监听端口,每连接一个客户端生成一个 `Socket` 实例通信 | | **多线程处理并发** | 每个客户端由独立线程处理,避免阻塞其他连接,实现并发服务 | | **线程安全集合** | 使用 `ConcurrentHashMap` 和 `AtomicInteger` 保证多线程下数据一致性和唯一ID生成 | ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值