【仓颉语言入门 · 第二篇 第 21 课】
【仓颉语言入门 · 第二篇 第 21 课】并发容器:ConcurrentHashMap、并发队列与 BlockingQueue
第一篇第 26~28 课我们讲了线程、
Mutex、AtomicInt64和条件变量,靠着自己拼装原语把并发程序写对了。但那种写法有个隐患:锁是「约定」,不是「约束」——只要有一处忘了加锁,编译期不会提醒你,运行期也不会立刻报错,而是在某个深夜的生产环境里变成一串错误的数字。这一课讲仓颉给出的另一条路:std.collection.concurrent里的并发容器。它们把同步做进了数据结构内部,你像用普通集合一样用,多线程天然安全。我们会实测普通HashMap并发写会怎么崩、ConcurrentHashMap的正确用法与几个必须知道的坑、ConcurrentLinkedQueue与阻塞队列怎么搭生产者-消费者,最后诚实地谈一件事:并发容器并不比加锁快。
本文所有代码、输出与报错文案均在仓颉 SDK 1.2.0 下逐行实测编译运行通过;性能数字在本机(i5-1135G7,4 核 8 线程,Windows)实测,同一台机器重复运行会有波动,结论以相对关系为准。
目录(第二篇导航)
第一篇《仓颉语言入门》共 30 课已完结(环境搭建 → 语法 → 函数 → 类型系统 → 工程化 → 并发 → 两场实战)。第二篇在更高的起点上继续,共 26 课、4 个模块:
| 模块 | 课次 | 内容 |
|---|---|---|
| 一、补遗篇 | 01~06 | 正则、时间日期、命令行参数、随机数、加密哈希、二进制处理 |
| 二、数据库篇 | 07~15 | 入门与连接、建表、查询、插入、更新删除、预编译防注入、事务、手写 DAO、综合小实战 |
| 三、进阶硬核篇 | 16~22 | 反射、宏基础、deriving 实战、FFI 基础、FFI 进阶、并发容器、对象池 |
| 四、实战项目篇 | 23~26 | HTTP 基础、HTTP 进阶、多线程日志分析器、简易爬虫 |
- 正则表达式(std.regex)
- 时间日期处理(std.time)
- 命令参数解析(std.argopt)
- 随机数与概率(std.random)
- 加密与哈希(std.crypto)
- 二进制数据处理(std.binary)
- 数据库入门:认识 std.database.sql,连上 MySQL
- 建表:用代码定义表结构
- 查询:SELECT 与结果集遍历
- 插入:INSERT、占位符与自增主键
- 更新与删除:UPDATE / DELETE
- 预编译与防 SQL 注入
- 事务:批量操作的成败一致
- 手写 DAO 数据访问层
- 数据库综合小实战
- 反射:在运行时打量类型
- 宏基础:std.ast 与 quote
- deriving 宏实战
- 与 C 互操作基础:FFI 调用 C 函数
- FFI 进阶:结构体、指针与编译配置
- 并发容器:并发安全的集合(本文)
- 对象池与资源复用
- 实战三:HTTP 服务端基础
- 实战四:HTTP 进阶与 JSON 接口
- 实战五:多线程日志分析器
- 实战六:简易爬虫
一、先看清楚敌人:普通 HashMap 并发写会怎样
第 8 课我们学过 HashMap,当时只有单线程,怎么用都对。现在把它放进 8 个线程里,每个线程往不同的键上写 5 万条:
package concurrent21
import std.collection.*
main(): Int64 {
let workers = 8
let perWorker = 50000
let m = HashMap<Int64, Int64>()
let hs = Array<Future<Unit>>(workers) { w =>
spawn {
for (i in 0..perWorker) {
let k = Int64(w) * Int64(perWorker) + Int64(i)
m[k] = k
}
}
}
for (h in hs) {
h.get()
}
println("size = ${m.size}")
return 0
}
按理说键互不重叠,最后应该是 size = 400000。实测不是——运行几次必崩:
An exception has occurred:
IndexOutOfBoundsException: The length of the array is 406, but the index is 406.
at std.collection::HashMap<...>::add(T0, T1)(std/collection\hash_map.cj:532)
at std.collection::HashMap<...>::[](T0, T1)(std/collection\hash_map.cj:1031)
at concurrent21::main::lambda.0::lambda.0()(src/main.cj:15)
几点必须注意:
- 上面只是其中一个崩溃线程的输出,实际会打印好几条,每次的数组长度都不一样(本轮出现过
406、913、2053) - 最后那行
size = 400000根本没有打印出来——程序在h.get()处就终止了 - 把
for (h in hs) { h.get() }用try { ... } catch (e: Exception) { println("主线程捕获到: ${e}") }包起来再跑,会看到:
主线程捕获到: IndexOutOfBoundsException: The length of the array is 181, but the index is 181.
也就是说:子线程里发生的异常,会由 Future.get() 原样抛回主线程。这个机制在第 28 课已经用过,这里正好看清它救了你一命——否则你会以为「跑完了,没输出而已」。
为什么会越界
HashMap 内部是一块数组加链表(元素多了会转成红黑树)。写入元素时如果负载因子超限,它会扩容:申请一块更大的数组,把老元素逐个搬到新数组里,同时 size 计数器加一。
这三个动作全都不是原子的。于是:
- 线程 A 正在扩容,手上拿的是新数组(长度 406)
- 线程 B 用旧的容量算出了下标(假设 913),往数组里写
- 越界,
IndexOutOfBoundsException
即使侥幸没越界,还有第二种崩坏:size 是普通整数,size++ 在机器指令层面是「读—加—写」三步,两个线程同时执行就会互相覆盖,计数偏小;反过来,同一键被两个线程同时插入时,两份都可能留在链表里,size 反而偏大。这类错误不抛异常、不报错,只是数字悄悄错掉——比崩溃难查一百倍。
一句话记住:
HashMap、ArrayList、HashSet这些普通集合,一律不能多线程并发写。
二、三条路线:加锁、原子类型、并发容器
想在多线程里共享一个「键值表」,仓颉里有三条合法路线。先看实测对比,再讲各自适用场景:
package concurrent21
import std.collection.*
import std.collection.concurrent.*
import std.sync.*
main(): Int64 {
let workers = 8
let perWorker = 1000
// 方案 B:Mutex 保护普通 HashMap
let m1 = HashMap<Int64, Int64>()
let lock = Mutex()
let hs1 = Array<Future<Unit>>(workers) { w =>
spawn {
for (i in 0..perWorker) {
let k = Int64(w) * Int64(perWorker) + Int64(i)
lock.lock()
m1[k] = k
lock.unlock()
}
}
}
for (h in hs1) {
h.get()
}
println("Mutex + HashMap size = ${m1.size}")
// 方案 C:ConcurrentHashMap
let m2 = ConcurrentHashMap<Int64, Int64>()
let hs2 = Array<Future<Unit>>(workers) { w =>
spawn {
for (i in 0..perWorker) {
let k = Int64(w) * Int64(perWorker) + Int64(i)
m2.add(k, k)
}
}
}
for (h in hs2) {
h.get()
}
println("ConcurrentHashMap size = ${m2.size}")
return 0
}
输出:
Mutex + HashMap size = 8000
ConcurrentHashMap size = 8000
两个都对了。三条路线的定位:
| 路线 | 写法 | 适用场景 | 代价 |
|---|---|---|---|
| A. 什么都不做 | m[k] = v |
只在单线程里用 | 并发下崩溃或数据错乱 |
B. Mutex + 普通集合 |
lock.lock(); m[k] = v; lock.unlock() |
临界区里不止一个操作,需要「几步一起原子」 | 粒度粗,一把锁把所有线程串成队列;漏加锁就前功尽弃 |
| C. 原子类型 | AtomicInt64、AtomicBool |
单纯的计数、标志位 | 只能存一个值,装不下表/队列 |
| D. 并发容器 | ConcurrentHashMap、ConcurrentLinkedQueue、BlockingQueue |
容器本身要被多线程共享 | 单次操作比普通容器慢 |
方案 B 有一个容易忽略的陷阱:锁的范围要覆盖整个逻辑操作。下面这种「先查再插」的写法,即使每行都加了锁,逻辑上仍然是错的:
// 错误示范:三段式非原子
lock.lock()
let exists = m.contains(key)
lock.unlock()
if (!exists) {
lock.lock()
m[key] = value
lock.unlock()
}
两条线程可能同时判定「不存在」,然后都去插入。要修就得把三段包进同一把锁里——这也正是并发容器存在的意义:它把这些「组合操作」做成了原子的单次调用(第五节)。
三、ConcurrentHashMap 基础用法
ConcurrentHashMap<K, V> 位于 std.collection.concurrent,实现了 ConcurrentMap<K, V> 接口。它对外暴露的 API 和普通 HashMap 很像,但所有会改结构的操作都是线程安全的。
package concurrent21
import std.collection.concurrent.*
main(): Int64 {
let m = ConcurrentHashMap<String, Int64>()
m.add("apple", 3)
m["banana"] = 5
m.add("cherry", 7)
println("size = ${m.size}")
println("apple = ${m.get("apple")}")
println("contains(\"banana\") = ${m.contains("banana")}")
println("contains(\"grape\") = ${m.contains("grape")}")
println("remove(\"cherry\") = ${m.remove("cherry")}")
println("after remove size = ${m.size}")
println("replace(\"banana\", 9) = ${m.replace("banana", 9)}")
println("banana now = ${m.get("banana")}")
println("addIfAbsent(\"apple\", 100) = ${m.addIfAbsent("apple", 100)}")
println("addIfAbsent(\"grape\", 100) = ${m.addIfAbsent("grape", 100)}")
println("grape now = ${m.get("grape")}")
let r = m.entryView("apple", { v =>
v.value = Some(v.value.getOrThrow() + 1)
})
println("entryView apple = ${r}")
println("apple now = ${m.get("apple")}")
for ((k, v) in m) {
println("${k} => ${v}")
}
return 0
}
实测输出:
size = 3
apple = Some(3)
contains("banana") = true
contains("grape") = false
remove("cherry") = Some(7)
after remove size = 2
replace("banana", 9) = Some(5)
banana now = Some(9)
addIfAbsent("apple", 100) = Some(3)
addIfAbsent("grape", 100) = None
grape now = Some(100)
entryView apple = Some(4)
apple now = Some(4)
grape => 100
apple => 4
banana => 9
逐条读一遍:
add(k, v)与m[k] = v都是插入/覆盖,等价get(k)返回Option<V>——键不存在返回None,不抛异常,这是查询的标准姿势remove(k)返回被删的值Option<V>(键不存在时返回None,不抛异常)replace(k, v)返回旧值Option<V>(这里是Some(5)),键不存在时返回None且不插入addIfAbsent(k, v)只在新键时插入:已存在返回旧值Some(3),成功插入返回None(这里是None然后grape变成100)entryView(k, fn)是「读—改—写」的原子版本,下一节展开- 迭代用
for ((k, v) in m),顺序是哈希顺序,不是插入顺序(输出里grape排在apple前面就是证据)
构造时可以给初始容量,键数量可预估时能少几次扩容:
let m = ConcurrentHashMap<String, Int64>(16)
实测 size = 2, a = Some(1),行为与无参构造一致。
四、一个必须知道的坑:m[key] 会抛异常
普通 HashMap 里 m["不存在的键"] 的行为,ConcurrentHashMap 继承得一模一样——直接抛异常:
package concurrent21
import std.collection.concurrent.*
main(): Int64 {
let m = ConcurrentHashMap<String, Int64>()
m.add("apple", 3)
println("apple = ${m["apple"]}")
println("grape = ${m["grape"]}")
return 0
}
实测输出:
apple = 3
然后程序终止,报错:
An exception has occurred:
NoneValueException: Value does not exist!
at std.collection.concurrent::ConcurrentHashMap<...>::[](T0)(std/collection/concurrent\concurrent_hash_map.cj:804)
at concurrent21::main()(src/main.cj:9)
m[key] 的签名是「返回 V 而不是 Option<V>」,所以键不存在时它只能抛 NoneValueException。由此得两条规矩:
- 不确定键存不存在,一律用
m.get(key),拿到Option<V>自己判断 - 确定键一定存在(刚 add 过、或本就是自增计数器),才用
m[key],图那一点省事的语法糖
并发场景下第 2 条尤其危险:你刚 get 到值、另一个线程把它删了、你再 m[key] 就炸。「先判断后取值」在并发下本身就是错的——判断和取值之间有一道无法填补的时间缝。
五、原子组合操作:addIfAbsent、replace、entryView
并发编程真正的难点不是「单个操作原子」,而是组合操作原子。ConcurrentHashMap 用 CAS(比较并交换)把常见的组合都做成了单次调用。
最常见的组合是「不存在就插入,存在就累加」,即词频统计、缓存预热这类场景。entryView 就是干这个的,签名是:
entryView(key: K, fn: (MapEntryView<K, V>) -> Unit): ?V
回调里的 MapEntryView 只有一个 value: ?V 字段。注意它拿到的是一个「视图」,你对 value 的赋值会在回调整体结束后原子地写回 map。四种情形实测如下:
package concurrent21
import std.collection.concurrent.*
main(): Int64 {
let m = ConcurrentHashMap<String, Int64>()
m.add("apple", 3)
// 情形 1:缺失键,回调里什么都不做
let a = m.entryView("mango", { _ => () })
println("缺失键-不写 entryView = ${a}, contains = ${m.contains("mango")}")
// 情形 2:缺失键,回调里只读 v.value
let b = m.entryView("peach", { v =>
println(" 回调里 v.value = ${v.value}")
})
println("缺失键-只读 entryView = ${b}, contains = ${m.contains("peach")}")
// 情形 3:缺失键,回调里写入
let c = m.entryView("lemon", { v =>
v.value = Some(50)
})
println("缺失键-写入 entryView = ${c}, contains = ${m.contains("lemon")}, size = ${m.size}")
// 情形 4:已存在键,回调里只读
let d = m.entryView("apple", { v =>
println(" 回调里 v.value = ${v.value}")
})
println("已存在键-只读 entryView = ${d}")
return 0
}
实测输出:
缺失键-不写 entryView = None, contains = false
回调里 v.value = None
缺失键-只读 entryView = None, contains = false
缺失键-写入 entryView = Some(50), contains = true, size = 2
回调里 v.value = Some(3)
已存在键-只读 entryView = Some(3)
归纳成一张表:
| 情形 | 回调里 v.value |
回调里赋值 | 返回值 | 键是否被插入 |
|---|---|---|---|---|
| 键不存在,不赋值 | None |
无 | None |
否 |
键不存在,赋值 Some(x) |
None |
v.value = Some(x) |
Some(x) |
是 |
键不存在,赋 None |
None |
v.value = None |
None |
否 |
| 键存在,不改 | Some(旧值) |
无 | Some(旧值) |
保持 |
| 键存在,改 | Some(旧值) |
v.value = Some(新值) |
Some(新值) |
覆盖 |
于是「累加」可以写成一次原子调用,全程不需要任何锁:
m.entryView(word, { v =>
v.value = Some((v.value ?? 0) + 1)
})
??是Option的空值合并运算符:左边是Some(x)就取x,是None就取右边(第 9 课讲可空类型时见过)。
⚠️ 实测提醒:1.2.0 的Option没有getOrElse方法,写成v.value.getOrElse({ => 0 })会报error: 'getOrElse' is not a member of enum 'Option<Int64>',请用??。
另外几个组合操作:
addIfAbsent(k, v):「不存在才插」 的原子版本。两个线程同时addIfAbsent同一个键,只有一个能成功插入,另一个拿到已存在的值——这正是第二节那段「先 contains 再插入」的错误示范的正确写法replace(k, v):「覆盖并返回旧值」 的原子版本putIfAbsent、enqueue/dequeue、带谓词的remove(k, pred)都属于旧 API,已标记废弃,编译器会给出 deprecation 警告,请改用addIfAbsent/add/remove(k)/entryView
六、实战一:多线程词频统计
把前面几节拼起来做一件正经事:4 个线程各处理 1000 个词,统计每个词出现次数。这里有个细节——ConcurrentHashMap 本身原子,但 m.get(w).getOrThrow() + 1 再写回不是原子的,所以计数器要用 AtomicInt64(第 27 课学过),两层原子叠起来才正确:
package concurrent21
import std.collection.concurrent.*
import std.sync.*
main(): Int64 {
let counts = ConcurrentHashMap<String, AtomicInt64>()
let words = ["cangjie", "electron", "sqlite", "ffi"]
let workers = 4
let perWorker = 1000
let hs = Array<Future<Unit>>(workers) { w =>
spawn {
for (i in 0..perWorker) {
let word = words[(i + w) % words.size]
counts.addIfAbsent(word, AtomicInt64(0))
counts.get(word).getOrThrow().fetchAdd(1)
}
}
}
for (h in hs) {
h.get()
}
println("总词数 = ${counts.size}")
for (word in words) {
println("${word} => ${counts.get(word).getOrThrow().load()}")
}
return 0
}
实测输出(每次运行都必须是这个结果,否则说明同步有问题):
总词数 = 4
cangjie => 1000
electron => 1000
sqlite => 1000
ffi => 1000
4 个线程 × 1000 次 = 4000 次累加,分摊到 4 个词上正好每个 1000。改写成 HashMap<String, Int64> + Mutex 也能得到同样结果,但并发容器版本少了一层锁的争抢,代码里也看不到 lock()/unlock() 的配对负担。
七、ConcurrentLinkedQueue:无锁队列
ConcurrentLinkedQueue<T> 是基于 CAS 的非阻塞队列,先进先出。它不设容量上限,add 永远不会因为「队列满」而阻塞:
package concurrent21
import std.collection.concurrent.*
main(): Int64 {
let q = ConcurrentLinkedQueue<Int64>()
println("空队列 isEmpty=${q.isEmpty()} size=${q.size}")
q.add(1)
q.add(2)
q.add(3)
println("入队 3 个后 size=${q.size}")
println("peek=${q.peek()}")
println("remove=${q.remove()}")
println("remove=${q.remove()}")
println("再次 peek=${q.peek()}")
let arr = q.toArray()
println("剩余元素=${arr}")
while (!q.isEmpty()) {
println("出队 ${q.remove()}")
}
println("清空后 isEmpty=${q.isEmpty()}")
println("空队列 peek=${q.peek()}")
println("空队列 remove=${q.remove()}")
return 0
}
实测输出:
空队列 isEmpty=true size=0
入队 3 个后 size=3
peek=Some(1)
remove=Some(1)
remove=Some(2)
再次 peek=Some(3)
剩余元素=[3]
出队 Some(3)
清空后 isEmpty=true
空队列 peek=None
空队列 remove=None
要点:
peek()/remove()返回Option<T>,空队列返回None而不是抛异常,这比BlockingQueue温和size是属性(不是方法),isEmpty()是方法,别写反toArray()打印成[3],是Array的默认toStringenqueue/dequeue是废弃 API(老名字),用add/remove- 类型别名
NonBlockingQueue<T>依然能用,但编译会有警告:
warning: type alias 'NonBlockingQueue' is deprecated. Use ConcurrentLinkedQueue instead.
八、BlockingQueue:会「等」的队列
ConcurrentLinkedQueue 的问题是:队列空了,消费者只能自己 while 轮询,白烧 CPU。std.collection.concurrent 里另有两个阻塞队列,专门解决这件事:
ArrayBlockingQueue<T>(capacity: Int64):定长,内部数组,容量必须在构造时给定LinkedBlockingQueue<T>(capacity: Int64):链表实现,同样要给定容量
它们的语义是:入队时队列满 → 阻塞等空位;出队时队列空 → 阻塞等元素
package concurrent21
import std.collection.concurrent.*
import std.time.*
main(): Int64 {
let q = ArrayBlockingQueue<Int64>(3)
println("capacity=${q.capacity} size=${q.size}")
println("tryAdd(1)=${q.tryAdd(1)}")
println("tryAdd(2)=${q.tryAdd(2)}")
println("tryAdd(3)=${q.tryAdd(3)}")
println("tryAdd(4)=${q.tryAdd(4)}")
println("size=${q.size}")
println("peek=${q.peek()}")
println("tryRemove=${q.tryRemove()}")
println("size=${q.size}")
println("remove(50ms)=${q.remove(Duration.millisecond * 50)}")
let empty = ArrayBlockingQueue<Int64>(3)
let t0 = DateTime.now()
let v = empty.remove(Duration.millisecond * 100)
println("空队列 remove(100ms)=${v}, 实际等待 ${(DateTime.now() - t0).toMilliseconds()} ms")
return 0
}
实测输出:
capacity=3 size=0
tryAdd(1)=true
tryAdd(2)=true
tryAdd(3)=true
tryAdd(4)=false
size=3
peek=Some(1)
tryRemove=Some(1)
size=2
remove(50ms)=Some(2)
空队列 remove(100ms)=None, 实际等待 101 ms
三组方法要分清,这是本课最容易混的地方:
| 方法 | 队列满/空时的行为 | 返回值 |
|---|---|---|
add(e) / remove() |
阻塞,直到成功 | Unit / E(非 Option) |
tryAdd(e) / tryRemove() |
立即返回,不成功就失败 | Bool / Option<E> |
remove(duration) |
最多等 duration,超时放弃 |
Option<E> |
注意 remove() 无参版本返回的是 E 而不是 Option<E>——它保证拿到值,代价是队列空时会一直挂着。这就是第八节标题里「会等」的含义。
九、选型速查
| 需求 | 用什么 | 备注 |
|---|---|---|
| 多线程共享键值表 | ConcurrentHashMap<K, V> |
位于 std.collection.concurrent |
| 多线程共享集合(不需要值) | ConcurrentHashMap<K, Bool> |
没有 ConcurrentHashSet |
| 无界、非阻塞队列 | ConcurrentLinkedQueue<T> |
add/peek/remove,永远不会堵 |
| 有界、阻塞队列 | ArrayBlockingQueue<T>(n) / LinkedBlockingQueue<T>(n) |
容量必填,自带背压 |
| 计数器 | AtomicInt64 / AtomicBool |
fetchAdd、load |
| 多步操作要原子 | addIfAbsent / replace / entryView |
不要自己拼「查—判断—写」 |
| 大块临界区(改多个共享对象) | Mutex |
容器原子操作覆盖不到时用 |
| 读多写极少的配置表 | 普通 HashMap(写完后不再改) |
别被"并发"两个字吓到 |
十、常见错误与避坑
| 现象 | 原因 | 处理 |
|---|---|---|
多线程写 HashMap 报 IndexOutOfBoundsException(栈顶 hash_map.cj:532) |
扩容过程非原子,线程用旧容量算出越界下标 | 换 ConcurrentHashMap,或整段用 Mutex 包住 |
程序没崩,但 size 数字不对(偏大/偏小) |
size 自增与节点插入非原子,重复键被插了两份 |
同上;这类错不报异常,最难查 |
ConcurrentHashMap 取值报 NoneValueException: Value does not exist! |
用了 m[key] 且键不存在 |
改用 m.get(key) 返回 Option 判断 |
「先 get 判断、再插入」偶发重复数据 |
判断与写入之间有竞态,锁只包住了其中一段 | 用 addIfAbsent / entryView 一次原子完成 |
编译报 error: undeclared identifier 'ConcurrentHashSet' |
标准库没有这个类型 | 用 ConcurrentHashMap<K, Bool> 模拟 |
编译警告 'NonBlockingQueue' is deprecated |
旧类型别名 | 换 ConcurrentLinkedQueue |
编译警告 'putIfAbsent' is deprecated |
旧方法名 | 换 addIfAbsent;remove(k, 谓词) 换 remove(k) 或 entryView |
编译报 'getOrElse' is not a member of enum 'Option<Int64>' |
Option 上没有 getOrElse |
用空值合并运算符 v.value ?? 0 |
主线程 add 卡住不动 |
有界队列已满,add 在等空位 |
主线程用 tryAdd;或先起消费者再起生产者 |
消费者 remove() 永远挂着 |
队列空且没有生产者再入队;或哨兵数量不足 | 保证每个消费者都有一个哨兵;或 remove(超时) |
| 判定"并发容器更快"后性能反而下降 | CHM 单次操作开销是 HashMap 的 2~4 倍 |
只读/单线程场景用普通 HashMap(第十一节) |
遍历 ConcurrentHashMap 期望拿到插入顺序 |
迭代顺序是哈希顺序 | 需要顺序就自己用 ArrayList 记录键 |
十一、常用 API 速查
| API | 作用 |
|---|---|
ConcurrentHashMap<K, V>() / (capacity: Int64) |
建表,可给初始容量 |
m.add(k, v) / m[k] = v |
插入或覆盖 |
m.get(k): ?V |
查值,不存在返回 None |
m[k]: V |
查值,不存在抛 NoneValueException |
m.contains(k): Bool |
键是否存在(没有 contains(k, v) 版本,实测编译报参数个数错误) |
m.remove(k): ?V |
删键并返回被删的值,不存在返回 None |
m.replace(k, v): ?V |
覆盖并返回旧值 |
m.addIfAbsent(k, v): ?V |
不存在才插入;成功返回 None,已存在返回旧值 |
m.entryView(k, fn): ?V |
原子「读—改—写」,回调里给 v.value 赋值才写回 |
m.size |
键值对数量(属性) |
for ((k, v) in m) |
迭代,哈希顺序 |
ConcurrentLinkedQueue<T>() |
无界非阻塞 FIFO |
q.add(e) / q.peek(): ?T / q.remove(): ?T |
入队 / 看队头 / 出队 |
q.isEmpty() / q.size / q.toArray() |
判空 / 数量 / 转数组 |
ArrayBlockingQueue<T>(n) / LinkedBlockingQueue<T>(n) |
有界阻塞队列 |
q.add(e) |
入队,满则阻塞 |
q.tryAdd(e): Bool |
入队,满则返回 false |
q.remove(): T |
出队,空则阻塞 |
q.remove(d: Duration): ?T |
出队,超时返回 None |
q.tryRemove(): ?T |
出队,空则返回 None |
q.peek(): ?T / q.capacity / q.size |
看队头(不删)/ 容量 / 当前数量 |
十二、课后练习
- 复现崩溃(必做):把第一节的 8 线程
HashMap程序原样跑 3 次,记录每次的The length of the array is ...数字,再用try/catch包住h.get()验证异常确实回到了主线程。 - 词频迁移:把第六节的词频统计改用
Mutex+HashMap<String, Int64>重写一遍,确认结果同样是每个词 1000,并对比两种写法的代码行数与锁的个数。 - 用 map 当 set:用
ConcurrentHashMap<String, Bool>写一个 4 线程去重的程序(每个线程往同一批随机词里addIfAbsent),最后打印size,确认结果等于词的种类数。 - 思考题:不加锁的
HashMap竟然比ConcurrentHashMap快,为什么?(提示:只读阶段谁在争用?get里的同步检查有没有必要?)如果想在「读多写少」场景同时拿到 CHM 的正确性和普通HashMap的速度,该用什么思路?
下节预告
并发这一课的落点是「共享数据别写坏」,下一课换一个角度——共享的对象太多了怎么办。频繁 new 对象、频繁建数据库连接、频繁开线程池,都会让 GC 和系统调用变成瓶颈。下一课《对象池与资源复用》讲:怎么用 ConcurrentLinkedQueue 写一个最简单的对象池(正好复用本课第七节)、池的容量与回收策略怎么设计、连接池和线程池为什么本质是同一套思路,以及「池化」在什么情况下反而更慢。
系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.2.0)编写。本文中所有代码、输出与报错文案均在 SDK 1.2.0 下逐行实测编译运行通过;性能数字来自本机(i5-1135G7,4 核 8 线程)实测,仅反映相对关系,不同机器、不同负载下数值会有差异。如遇 SDK 版本差异导致的 API 出入,以你本地版本为准,欢迎评论区交流。
💬 遇到问题?扫码联系作者
跟着课程练习时,如果在 SDK 安装、环境变量配置、编译报错或调试上卡住,欢迎扫码加作者企业微信直接咨询(请备注"仓颉课程"):

离线环境下图片可能加载不出来,也可以在 CIDE 菜单 Help ▸ 联系作者 / Contact 中查看同一张二维码(应用内置兜底图,无需联网)。
📥 工具下载
本系列全程使用的仓颉 IDE —— CIDE(免费开源、社区版):
- GitCode 仓库 / 安装包下载:https://gitcode.com/wp_upala/cide
- 打开页面后进入 发行版(Releases),两种包任选其一:
- 安装版:下载
CIDE-<版本>-x64-Setup.exe,双击安装,适合日常长期使用; - 免安装版(Portable):下载
CIDE-<版本>-x64-Portable.zip,解压到任意目录即用,不写注册表、不留安装痕迹,拷到 U 盘也能在别的电脑直接运行(包内附《使用说明.txt》)。适合先试用、或在受限电脑上学习本系列课程。
- 安装版:下载
- 仓颉 SDK 请前往仓颉编程语言官网下载:https://cangjie-lang.cn

浙公网安备 33010602011771号