news 2026/9/9 16:58:28

深入剖析ConcurrentHashMap:从JDK7到JDK8的实现与实战避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入剖析ConcurrentHashMap:从JDK7到JDK8的实现与实战避坑指南

从一次线上事故说起:为什么并发场景必须拥抱ConcurrentHashMap

大概两年前,我负责的一个订单服务在大促期间突然出现CPU飙升,紧接着一批请求超时。刚开始大家都以为又是数据库连接池被打满了,结果一查线程栈,发现大量线程卡在HashMap.put方法上。后来才定位到,代码里一个被多个线程共享的订单缓存Map是普通HashMap,并发写入触发了JDK 7的扩容死循环——链表成环,CPU被打爆,服务直接雪崩。

那次事故之后,我开始认真研究ConcurrentHashMap。系列第一篇我们聊了ConcurrentHashMap的基础使用场景和它与HashMap、Hashtable的粗略对比,这篇直接往深了走,把两代实现的核心差异、源码级的关键方法拆解、计数器优化原理,以及我实际使用中踩过的坑一次性讲透。不管你是准备面试,还是在写高并发服务时想真正用好它,这篇应该都能给你一些硬货。

  1. 从一次生产事故说起:为什么并发场景必须拥抱ConcurrentHashMap

1.1 HashMap为什么在并发下会出人命

很多人面试的时候都能背出“HashMap线程不安全”,但你要问他到底怎么个不安全法,多半只会说“并发put会丢数据”。丢数据确实是问题,但最致命的是JDK 7及更早版本中,HashMap在扩容时并发写入会形成环形链表,一旦链表成环,下次get这个key的时候就会发生死循环,CPU瞬间打满。

我这里可以给一个简化版的复现思路。JDK 7的transfer方法在扩容迁移时,采用头插法把旧桶的链表搬到新桶。假设两个线程同时执行迁移,线程A在搬完链表的一部分后挂起,线程B完成了整个迁移,随后线程A恢复执行,继续按自己的引用关系搬运节点,链表就会反向形成环。这个环一旦生成,后续任何一次遍历这个桶的操作都无法终止。

JDK 8修复了扩容死循环的问题,改用尾插法,但线程不安全的问题依然存在。并发put可能导致数据覆盖,最典型的是两个线程同时判断某个key不存在,然后同时执行addEntry,后写入的值覆盖掉先写入的值,甚至更糟,modCount和实际size不一致,迭代时抛出ConcurrentModificationException。所以,多线程环境下用HashMap,本质上就是在赌系统不会出事故,而生产环境最不能赌的就是这个。

1.2 Hashtable和Collections.synchronizedMap为什么也不够用

既然HashMap不行,很多人会退而求其次,用Hashtable或者Collections.synchronizedMap(new HashMap<>())。这两个方案在并发下是安全的,但它们的同步策略是锁住整个Map对象。所有读操作和写操作都争同一把锁,并发越高,锁竞争越激烈,性能衰减就越明显。

我实际压过一轮数据,在8线程并发读写同一个Map的场景下,Hashtable的吞吐量大概只有ConcurrentHashMap的1/5到1/4。原因不难理解,Hashtable的getput都是用synchronized修饰的,读操作本来是无状态的,却被迫和写操作排队竞争锁。用一句话概括:Hashtable保证了安全,但牺牲了并发能力。

更微妙的还有复合操作的问题。synchronizedMap虽然单个方法安全,但像containsKey之后再put这种复合操作,如果没有在外部加锁,一样会出现竞态条件。所以从JDK 5开始,Java官方就在java.util.concurrent包里提供了ConcurrentHashMap,用粒度更细的并发控制策略来解决这个两难问题。

1.3 ConcurrentHashMap解决了什么核心矛盾

ConcurrentHashMap的核心设计目标很清晰:在不牺牲线程安全的前提下,最大化并发吞吐。它的思路不是用一把大锁挡住所有线程,而是把数据分段或分桶,让不同线程操作不同数据段时根本不需要互相等待。

JDK 7时代用的是分段锁(Segment),JDK 8开始换成了CAS + synchronized。无论哪一代,核心思想都是降低锁粒度,让并发操作尽量并行执行。同时,读操作被设计成几乎无锁,只在极端情况下才需要同步等待,这一点对于读多写少的业务场景特别友好。

这个设计直接解决了我在开头提到的线上问题:多个线程同时读写同一个Map,不再因为一把锁而互相阻塞,扩容也不再需要暂停所有读写线程。当然,它也不是银弹,弱一致性的迭代器、某些复合操作需要额外加锁、某些场景下内存占用偏高等问题,都还在。这些坑我会在后面的实战章节专门讲。

  1. 两代设计的分水岭:JDK 7的分段锁与JDK 8的CAS+synchronized

2.1 JDK 7:Segment分段锁的设计逻辑

JDK 7的ConcurrentHashMap内部维护一个Segment数组,Segment本身继承自ReentrantLock。每个Segment负责管理一部分HashEntry桶,默认有16个Segment,也就是说并发级别默认是16。写入时先定位key应该落在哪个Segment,然后只需要锁住这个Segment即可,其他Segment的读写完全不受影响。

put操作的大致流程是:先通过key的hash值定位Segment,然后调用Segment的put方法。在Segment内部,先尝试tryLock获取锁,如果拿不到就进入scanAndLockForPut,在自旋等待获取锁的同时,可以先遍历链表创建好待插入的节点,这样等锁拿到之后插入操作几乎就完成了,减少了持锁时间。

这里值得说一下为什么Segment继承ReentrantLock而不是直接用synchronized。JDK 7时代synchronized还没有引入锁升级机制(偏向锁、轻量级锁是后来才完善的),在竞争激烈的场景下synchronized性能并不理想。ReentrantLock支持可中断获取锁、支持超时、支持公平锁,并且tryLock可以避免线程无限期阻塞。这些能力对于高并发容器来说非常重要。

但分段锁也有明显的短板。首先,Segment数组的容量一旦在构造时确定,就不能再扩容,因为hash到Segment的映射不能变。其次,某些操作无法做到全局精确,比如size()需要先不加锁地累计每个Segment的count,如果两次累计结果不一致,就要加锁重新统计,这个开销在Segment很多时会很高。更重要的是,Segments划分之后,一个Segment内部仍然是一个完整的哈希表,当某个Segment内的链表过长时,定位效率会下降,而没有办法像JDK 8那样把链表转成红黑树。

2.2 JDK 8:用Node数组+CAS+synchronized重构底层

JDK 8的ConcurrentHashMap做了一次彻底的推倒重来。它放弃了Segment数组这种二级索引结构,直接使用一个Node<K,V>[] table,和HashMap的结构基本对齐。并发控制策略变成:

  • 如果桶位为空,用CAS原子地把新节点放入桶位,整个过程无锁。
  • 如果桶位不为空,对桶位的头节点加synchronized锁,然后执行链表或红黑树的插入。

为什么敢用synchronized了?因为JDK 6之后synchronized做了大量优化,引入了偏向锁、轻量级锁和重量级锁的升级路径。在并发竞争不激烈时,synchronized的锁开销甚至比ReentrantLock还低;当竞争激烈时,会升级为重量级锁,性能也可控。最关键的是,JDK 8的锁粒度已经从Segment级别缩小到了单个桶级别,两个线程操作同一个Segment中不同桶位时,完全不会互相阻塞。

我在看过JDK 8的putVal源码之后的一个直观感受是,它把无锁和加锁两种策略配合得非常精细:用CAS解决“空桶的并发写入”,因为空桶写入是最常见的情况;用synchronized解决“非空桶的链表或树操作”,因为这种情况必然涉及对已有结构的修改,需要保证线程安全。这种分区治理的思路,比统一加锁要聪明得多。

2.3 扩容状态机与ForwardingNode的引入

JDK 8还引入了一个很重要的辅助类ForwardingNode。当某个桶迁移完成之后,table中这个桶位会放一个ForwardingNode,它的hash值固定为MOVED(-1),内部持有新表的引用nextTable。其他线程在进行putget操作时,如果发现桶位是ForwardingNode,就知道扩容正在进行,要么帮忙迁移数据,要么去新表中继续查找。

ForwardingNode的意义不只是标记迁移状态,它还是多线程协助扩容机制的基础。扩容不是由一个线程独自完成全部搬迁,而是先由触发扩容的线程迁移一部分桶,然后其他写操作的线程发现ForwardingNode后,也会加入到迁移工作中来。每个线程迁移一个步长(stride)的桶,大家共同推进迁移进度,直到所有桶都搬迁完成。这种机制在JDK 7里是没有的,JDK 7的rehash过程是独占式的大规模搬迁,大Map扩容时会短暂阻塞所有写线程。

这个机制的巧妙之处在于,迁移过程是“无感并发”的:迁移完的桶位用ForwardingNode标记,未迁移的桶位继续提供读写服务。从外部看,整个Map在扩容期间可以正常读写,只是每次读写可能多一跳转发。这种设计把扩容对业务的影响压到了最低。

  1. 源码级拆解核心方法:put、get、transfer背后的并发协作

3.1 putVal:从hash散列到CAS插入的完整链路

put方法最终调用putVal。JDK 8的putVal在加锁前做了大量优化,我拆开来看。

第一层是计算hash。spread()方法把key的hashCode高位和低位做异或,再把结果和HASH_BITS做与操作,目的是尽量打散hash值,并确保结果为正数,避免和特殊标记值冲突(比如MOVED是-1,TREEBIN是-2)。这个扰动函数和HashMap的hash方法思路一致,但多了一步移除符号位。

第二层是死循环加CAS。用for (Node<K,V>[] tab = table;;)开启一个无限循环,在循环内判断table是否需要初始化,需要就调用initTable()。如果定位到的桶位是null,就尝试用casTabAt把新节点放进去。这里用的是Unsafe.compareAndSwapObject,直接操作内存地址,保证原子性。CAS成功则break,失败说明有其他线程抢先写了,继续下一轮循环。

第三层是处理hash为MOVED的情况。如果桶位的头节点是ForwardingNode,说明当前正在进行扩容,当前线程不会傻等,而是调用helpTransfer参与迁移。这里体现了一个重要设计:每次写入都可能帮助扩容,而不是阻塞等待扩容完成。

第四层是真正的加锁插入。如果桶位不为空也不是ForwardingNode,就synchronized锁住桶位的头节点f,然后判断f是链表节点还是树节点。如果是链表(fh >= 0),遍历链表查找相同key,找到了就替换value,找不到就尾插法追加新节点;如果链表长度达到TREEIFY_THRESHOLD(8),会调用treeifyBin尝试转为红黑树。注意treeifyBin里有个关键判断,如果table长度小于MIN_TREEIFY_CAPACITY(64),先不转树而是扩容,因为当哈希表还很小时,链表过长大概率是hash分布问题,重新扩容摊平比转树更划算。

putVal流程走完,最后调用addCount(1L, binCount)来更新元素个数。这一步也不是简单的加一,背后的逻辑在计数器的章节详聊。

3.2 get:为什么读取操作完全无锁

get方法在绝大多数情况下是无锁的,这也是ConcurrentHashMap读性能好的核心原因。它的代码流程很短:

  1. 计算key的hash,找到对应的桶位下标。
  2. 读取桶位头节点e,如果e为null直接返回null。
  3. 如果e的hash值等于要找的hash,且key相等,直接返回e的value。
  4. 如果e是TreeBin节点或ForwardingNode,走对应查找逻辑。
  5. 如果是普通链表节点,遍历链表查找。

这里有一个设计要点值得展开:get为什么不需要加锁?核心在于table数组本身是用volatile修饰的。volatile保证了table引用的可见性,但是数组内部的元素却不一定可见。所以get读取桶位元素时,不能直接通过tab[i]读取,而是调用tabAt(tab, i),这个方法底层用Unsafe.getObjectVolatile读取,保证每次读取都拿到最新的内存值。

同理,putVal里写入桶位元素时,用casTabAt或者setTabAt,底层是Unsafe.putObjectVolatile,保证写入对其他线程立即可见。

那是不是说所有读都无锁?也不全是。当桶位已经树化时,TreeBin内部的读写头节点用了读写锁机制,get操作获取的是读锁(用的是LockSupport的阻塞/唤醒,而不是传统的读写锁实现,但语义上类似读锁)。红黑树的写入会修改树结构,必须加锁保护。不过这只是极端场景,绝大多数桶位都是链表,get完全无锁。

我建议你在看源码时特别关注tabAtcasTabAtsetTabAt这三个方法,它们是整个并发安全的基石,也是理解volatile数组元素可见性问题的最佳教材。

3.3 transfer:扩容迁移中的多线程协作机制

transfer方法是我认为JDK 8 ConcurrentHashMap最复杂也最精彩的部分。它要完成的任务是:把旧table每个桶中的节点,按照新table的容量,重新计算位置并迁移过去。

先说步长stride。如果机器是多核CPU,每个线程迁移的桶数量最少是MIN_TRANSFER_STRIDE(16),否则按n / 8 / NCPU计算。这样做是为了避免线程粒度过细,频繁切换带来额外开销。

迁移过程中,核心是要对每个桶位加锁。但加锁的目的不是为了独占整个迁移过程,而是保证同一个桶位的迁移不冲突。加了synchronized之后,迁移完的桶位立即被置为ForwardingNode,这样其他线程就知道这个桶已经处理完了。

transfer最值得玩味的是lastRun优化。在遍历链表时,算法会先找到链表尾部连续一段“新位置相同”的节点,直接整段赋值给lnhn,而不是逐个节点处理。这是因为链表尾部节点在新table中的位置往往相同,可以批量搬运,省去大量单节点转链的操作。我自己在分析这段代码时,刚开始觉得这个优化很不起眼,但仔细算下来,在链表很长时它能减少很多内存分配和指针修改。

这里有一个很容易忽略的细节:迁移完成后,最后要把旧table中当前桶位的引用置为null,避免内存泄漏;同时还有一个advance变量控制外层循环推进到下一个桶位。整个迁移过程在外层for循环中完成,当前线程处理完自己分配的stride之后,如果发现transferIndex还有剩余桶,会继续领取新的迁移任务,直到transferIndex归零。

所以,ConcurrentHashMap的扩容并发度远高于JDK 7:触发扩容的线程负责初始化nextTable,之后所有写操作的线程都可以参与搬运。只要有一个线程发现ForwardingNode,它就会来搭把手。这种全民参与的风格,和JDK 7那种一个线程独自搬家的方式,性能差距在大型Map上非常明显。

  1. 计数器的秘密:size()为什么不准确,又是如何被优化的

4.1 一个LongAdder风格的CounterCell数组

ConcurrentHashMap需要维护元素个数,但又不能简单地用一个volatile int,因为高并发下所有线程都CAS同一个变量,竞争失败率会非常高,大量线程在自旋等待。

JDK 7的解决方案是每个Segment维护自己的count,统计size时把各个Segment的count加起来。JDK 8进一步演进,引入了baseCountCounterCell[]的组合。

简单说,初始时所有线程都CAS更新baseCount。如果CAS成功,一切顺利;如果CAS失败,说明发生了竞争,这时就会初始化CounterCell数组,把竞争的压力分散到多个格子中。每个线程通过ThreadLocalRandom.getProbe()确定自己应该更新哪个格子,然后CAS更新对应格子的value。这就好比一个安检口排队太长,就多开几个口子,效率自然大幅提升。

这个思路和LongAdder如出一辙,事实上JDK 8的ConcurrentHashMap也确实借鉴了LongAdder的布局思想。

4.2 size()的读取和精度取舍

size()方法返回的是sumCount()的结果:把baseCount加上所有CounterCell的value。注意,这个累加过程没有加锁,所以返回值可能和实际元素个数有细微偏差。在并发写入极其频繁的瞬间,size()返回的数字可能不是最新的。这在官方注释中也有说明,它是一个“估计值”,但实际使用中误差很小。

我在项目中见过有人把map.size() == 0作为判断逻辑,这种做法在并发环境下是不可靠的。如果你真的需要精确判断Map是否为空,建议使用isEmpty(),它的实现同样是一个估计值,但在判定为空时是相对可信的,因为元素只会增加不会凭空消失,如果sum为0那基本就是空了。如果业务上无法容忍任何误差,那就不应该用ConcurrentHashMap来统计精确数量,而是考虑用数据库计数、Redis计数器或原子变量配合事件通知的方式。

4.3 高并发写入时的性能对比实测

我在自己的测试环境做过一次压力对比,8个线程并发写入100万次,分别使用JDK 8的ConcurrentHashMap、Hashtable和synchronizedMap。结果为ConcurrentHashMap用时约0.8秒,Hashtable约4.2秒,synchronizedMap约4.5秒。而且随着线程数和写入量的增加,ConcurrentHashMap的优势还会扩大。

但要注意的是,这个性能优势建立在合理使用的前提下。如果每个key都落在同一个桶位,或者所有线程都在操作同一个key,那么锁竞争依然会非常激烈。比如你用同一个key做计数器累加,并发写入时依然会退化成近似串行。ConcurrentHashMap保证的是“不同key并发操作的性能”,而不是“同一key也能并发”。

  1. 实战中的参数选择与常见坑:来自线上环境的经验

5.1 初始容量真的不需要精确到你有多大数据

ConcurrentHashMap有扩容机制,和HashMap一样也有负载因子(默认0.75),那么初始容量该怎么设置?

很多人直接new ConcurrentHashMap<>(10000),以为这样能避免扩容。但这会带来一个容易忽略的问题:指定容量传入构造器后,会被转换成不小于该值的2的幂次方,而且内部实际做的是size * 2的扩容预留。也就是说,new ConcurrentHashMap<>(10000)实际初始化的table容量可能是16384,而不是10000。再加上负载因子0.75,实际存储阈值在约12288左右。如果你确实需要存10000条,这么做也没错,但如果你以为指定了10000就精确只分配10000,那就理解偏了。

我的建议是,如果业务数据规模比较确定,可以按预期元素个数 / 0.75来作为构造参数。比如预期存1万条,就传13334左右,这样既不会频繁扩容,也不会浪费太多内存。

5.2 computeIfAbsent的隐藏阻塞问题

在JDK 8中,computeIfAbsent是一个非常方便的方法,用于在key不存在时计算value。很多缓存库都用它来实现“没有就加载”的逻辑。但它有一个容易被忽略的性能陷阱:当key对应的桶位已经有值或者发生CAS竞争时,computeIfAbsent会进入synchronized块执行MappingFunction

如果你的MappingFunction执行得很慢,比如里面做了外部API调用、查数据库、复杂计算,那么所有操作同一个桶位的线程都会被阻塞在这把锁上。我在线上排查过一个接口超时问题,发现代码里用了ConcurrentHashMap.computeIfAbsent(cacheKey, key -> loadFromRemote(key)),loadFromRemote偶尔会慢到几百毫秒,结果其他请求全部堵在锁上,接口整体超时。

改进方案很简单:不要在MappingFunction里放重量级操作;如果必须放,考虑使用双检锁或者FutureTask包装,或者直接用Caffeine这类带异步加载能力的缓存框架。

5.3 迭代器弱一致性与复合操作的正确姿势

ConcurrentHashMap的迭代器是弱一致性的,意味着迭代过程中,其他线程对Map的修改不一定会反映到当前迭代中,但迭代器本身不会抛ConcurrentModificationException(除非结构损坏这种极端情况)。这对追求高吞吐的系统是友好的,但如果你依赖迭代过程中Map保持不变,那就会踩坑。

例如,某业务代码中需要“遍历所有元素并统计某种特征”,用迭代器遍历的同时另一个线程删除了部分元素,统计结果可能出现偏差。这不算Bug,是这个类的设计特性,但业务层面如果无法容忍,就需要自己对迭代过程加锁或采用拷贝快照的方式。

另外,putIfAbsent虽然是一个原子操作,但如果你的业务是“先检查某个状态,再决定是否put”,那么检查到put之间依然存在竞态窗口。这种情况下,需要把整段逻辑封装成使用synchronized锁住固定key的操作,或者使用compute系列方法,让整个判断和写入在一个原子操作内完成。

5.4 内存占用与链表转树的边界条件

当单个桶的链表长度超过TREEIFY_THRESHOLD(8)且table长度达到MIN_TREEIFY_CAPACITY(64)时,链表会转成红黑树。红黑树节点TreeNode的占用空间比普通Node大一些,这是为了换取更快的查询速度。

但如果你存储的key是均匀分布的(比如随机字符串),几乎不会触发树化,因为均匀hash下桶长度达到8的概率非常低。反过来,如果hash函数写得不好,或者key本身有严重的hash碰撞,那么链表会频繁转树,内存占用会比预期高不少。我在排查一个内存问题时,发现某个用object作为key的ConcurrentHashMap内存异常增长,原因是那个object的hashCode()实现有缺陷,大量对象落在同一个桶里,红黑树节点数量激增。最后换了key的类型,内存立刻恢复正常。

所以使用ConcurrentHashMap时,不要忽略key的hashCode()质量。写一个糟糕的hashCode,最坏情况下能让ConcurrentHashMap退化成链表+红黑树的结构,失去哈希表应有的O(1)查询优势。

5.5 为什么不要滥用ConcurrentHashMap做全局缓存

ConcurrentHashMap是并发安全的Map,但它不提供任何缓存淘汰策略。很多团队用它做应用内缓存,时间一长数据越积越多,最终触发频繁扩容,甚至出现内存接近溢出的问题。如果只是短期存少量热点数据,用它没问题;但如果数据规模不可控,或者需要过期淘汰、容量上限、统计命中率,就老老实实上Caffeine或者Redis,而不是自己用ConcurrentHashMap手写一套缓存。我在线上见过因为用ConcurrentHashMap做缓存然后数据无限增长,导致旧数据无法回收,触发老年代GC频繁,业务抖动好几轮才定位到根因的情况。

5.6 关于key为null的问题

ConcurrentHashMap不允许key或value为null,但和HashMap的null规则不同,HashMap在单独使用时允许key为null,而ConcurrentHashMap直接拒绝null key/value,在putVal开头就有if (key == null || value == null) throw new NullPointerException()

为什么这么设计?有个比较公认的解释是:在并发环境下,如果允许null value,get方法将无法区分“key不存在”和“key存在但value为null”,这会产生二义性。而如果是单线程的HashMap,我们可以用containsKey来辅助判断,但在并发条件下,这种检查没有了原子性保证。所以ConcurrentHashMap干脆不支持null,避免这个语义陷阱。

如果业务真的需要存null value,我的建议是换个方案:要么用Optional包装,要么存一个特殊标记对象,要么重新设计数据结构。硬用ConcurrentHashMap塞null,只会让代码在运行期时不时抛NPE,非常难排查。

结束语

写到这,ConcurrentHashMap的核心原理和实战要点基本都覆盖到了。回顾一遍,JDK 8版本最大的成功在于把“锁的粒度”和“无锁CAS”结合到了恰到好处的位置:空桶写入用CAS,非空桶写入锁单个桶,读取尽量无锁,扩容让所有写线程共同参与。这种设计思路不仅适用于理解这一个类,对你自己设计高并发组件也有很强的借鉴意义。

最后再分享一个小技巧:如果你在排查ConcurrentHashMap相关的性能问题,别急着看业务代码,先打开-XX:+PrintGCDetails看看GC频率,再通过jstack观察阻塞线程集中在哪个方法上。很多时候问题不在Map本身,而在于你给的初始容量太小导致频繁扩容,或者computeIfAbsent里放了重量级加载逻辑。把参数调对、把函数逻辑做轻,ConcurrentHashMap通常都能给你一个非常漂亮的并发吞吐。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 16:55:32

AD7888BRZ,8通道12位125kSPS低功耗SAR模数转换器

AD7888BRZ是ADI推出的微功耗多通道SAR型ADC&#xff0c;专为工业多通道传感巡检、便携式采集设备、电池供电测控系统、低速精密数据采集场景设计。芯片集成8路单端模拟输入、片内2.5V基准源、采样保持电路&#xff0c;支持12位精准采样、125kSPS高速吞吐&#xff0c;搭配宽电压…

作者头像 李华
网站建设 2026/9/9 16:55:04

彻底解决Cursor试用限制:1条命令重置机器码,免费额度立刻回来

彻底解决Cursor试用限制&#xff1a;1条命令重置机器码&#xff0c;免费额度立刻回来 【免费下载链接】go-cursor-help 解决Cursor在免费订阅期间出现以下提示的问题: Your request has been blocked as our system has detected suspicious activity / Youve reached your tri…

作者头像 李华
网站建设 2026/9/9 16:50:05

全体目光向我看起,2026到底线上教务系统哪家好呢!

全体目光向我看起&#xff0c;2026到底线上教务系统哪家好呢&#xff01; 网经社《2026教培SaaS与小程序化运营洞察》里有个挺直的白话&#xff1a;中小教培机构里&#xff0c;已用系统管排课的大概七成&#xff0c;但能跑通“排课—授课—作业—测评—续费”全链路的&#xff…

作者头像 李华
网站建设 2026/9/9 16:49:57

【计算机JAVA毕业设计案例】基于SpringBoot+Vue的互联网智慧医疗问诊系统的设计与实现 基于SpringBoot+Vue的医患咨询问诊管理系统的设计与实现(程序+文档+讲解+定制)

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java、小程序技术领域和毕业项目实战 ✌️技术范围&#xff1a;&am…

作者头像 李华
网站建设 2026/9/9 16:49:46

Function Calling 本质是语义协商而非函数调用

1. 这不是“调用”&#xff0c;是“协商”——Function Calling 的本质从来不是 API 调用你写好了一个get_weather(city: str, unit: str "celsius")函数&#xff0c;把它塞进tools列表里&#xff0c;喂给大模型&#xff0c;然后看着它输出一段 JSON&#xff1a;{&q…

作者头像 李华