【仓颉语言入门 · 第二篇 第 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 进阶、多线程日志分析器、简易爬虫
  1. 正则表达式(std.regex)
  2. 时间日期处理(std.time)
  3. 命令参数解析(std.argopt)
  4. 随机数与概率(std.random)
  5. 加密与哈希(std.crypto)
  6. 二进制数据处理(std.binary)
  7. 数据库入门:认识 std.database.sql,连上 MySQL
  8. 建表:用代码定义表结构
  9. 查询:SELECT 与结果集遍历
  10. 插入:INSERT、占位符与自增主键
  11. 更新与删除:UPDATE / DELETE
  12. 预编译与防 SQL 注入
  13. 事务:批量操作的成败一致
  14. 手写 DAO 数据访问层
  15. 数据库综合小实战
  16. 反射:在运行时打量类型
  17. 宏基础:std.ast 与 quote
  18. deriving 宏实战
  19. 与 C 互操作基础:FFI 调用 C 函数
  20. FFI 进阶:结构体、指针与编译配置
  21. 并发容器:并发安全的集合(本文)
  22. 对象池与资源复用
  23. 实战三:HTTP 服务端基础
  24. 实战四:HTTP 进阶与 JSON 接口
  25. 实战五:多线程日志分析器
  26. 实战六:简易爬虫

一、先看清楚敌人:普通 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 计数器加一。

这三个动作全都不是原子的。于是:

  1. 线程 A 正在扩容,手上拿的是新数组(长度 406)
  2. 线程 B 用旧的容量算出了下标(假设 913),往数组里写
  3. 越界,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。由此得两条规矩:

  1. 不确定键存不存在,一律用 m.get(key),拿到 Option<V> 自己判断
  2. 确定键一定存在(刚 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 的默认 toString
  • enqueue / 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 看队头(不删)/ 容量 / 当前数量

十二、课后练习

  1. 复现崩溃(必做):把第一节的 8 线程 HashMap 程序原样跑 3 次,记录每次的 The length of the array is ... 数字,再用 try/catch 包住 h.get() 验证异常确实回到了主线程。
  2. 词频迁移:把第六节的词频统计改用 Mutex + HashMap<String, Int64> 重写一遍,确认结果同样是每个词 1000,并对比两种写法的代码行数与锁的个数。
  3. 用 map 当 set:用 ConcurrentHashMap<String, Bool> 写一个 4 线程去重的程序(每个线程往同一批随机词里 addIfAbsent),最后打印 size,确认结果等于词的种类数。
  4. 思考题:不加锁的 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
posted @ 2026-11-01 12:06  upala  阅读(6)  评论(0)    收藏  举报