
JMM到底是什么别再和JVM内存模型搞混了做Java并发编程绕不开一个核心概念JMM全称Java Memory Model也就是Java内存模型。我见过太多同学在面试和技术讨论里把JMM说成堆、栈、方法区那些东西那是JVM内存模型Runtime Data Area完全是两码事。JMM不是描述对象怎么在内存里存放的它描述的是多线程并发场景下共享变量的读写是如何在多个线程之间达成一致的底层规则。这篇文章想用实际开发的经验把JMM这层窗户纸捅破。你不需要把JSR-133规范背下来但你需要理解JMM到底解决了什么问题、它内部是怎么运作的、以及它在日常编码中怎么指导我们写出正确的并发程序。如果你是准备面试的Java开发或者写并发代码经常感觉像在碰运气这篇文章应该能帮上大忙。先说结论JMM解决的根本问题只有一个——在多线程环境下一个线程对共享变量的写入什么时候、通过什么方式能被另一个线程看到以及这种可见性要付出什么样的代价。1. 为什么需要一套内存模型1.1 现代计算机的存储层级和缓存一致性要理解JMM先得看看硬件层面CPU和内存是怎么协作的。现代CPU的运算速度和内存的读取速度差距非常大如果CPU每次读写都直接访问物理内存那CPU大部分时间都在等待数据搬运。所以硬件层面做了层层缓存每个CPU核心有自己的L1/L2缓存多个核心共享L3缓存再往下才是主内存DRAM。CPU运行时先把数据从主内存读到缓存运算完再回写。这就带来一个核心问题缓存一致性。两个CPU核心同时读取了某个变量到各自的缓存核心A修改了这个值核心B还在用旧值那程序就出错了。硬件通过MESI这类缓存一致性协议来保证多个核心之间观察到的同一个缓存行是同步的。但是同步本身是有代价的缓存行失效、总线通信、写缓冲等待都会拖慢执行速度。为了性能CPU和编译器还会做指令重排序——在单线程语义不变的前提下把指令顺序打乱让流水线利用更充分。有了这套硬件背景JMM的定位就很清晰了。Java是一种跨平台语言不能假设代码只跑在x86上也不能假设所有CPU都实现了相同的缓存一致性协议。JMM要做的是在Java层定义一套统一的、跨平台的并发语义规范让Java程序员不需要了解底层硬件细节也能编写出行为可预测的并发代码。它要求每个JVM实现都必须遵守这套规范但具体怎么映射到CPU指令JVM自己决定。1.2 并发三大特性原子性、可见性、有序性JMM的所有规则最终都围绕三个特性展开。原子性指的是一个操作或多个操作要么全部执行且不被中断要么全部不执行。Java中对变量的简单读写除了long/double这种64位类型在某些32位JVM上可能拆成两次操作是原子的i这种读-改-写操作不是原子的所以并发场景下要靠锁或Atomic类来保证。可见性指的是一个线程修改了共享变量后其他线程能否马上看到这个修改。在JMM的规则下每个线程有自己的工作内存对应CPU缓存和寄存器直接修改工作内存里的值不立即刷新回主内存的话其他线程读到的就是旧值。volatile关键字、synchronized和Lock都能保证可见性。有序性指的是程序执行的顺序符合代码逻辑顺序。但JVM和CPU为了性能会做指令重排序单线程下没问题多线程下就会出现奇怪的现象。Java中通过volatile、synchronized、final以及显式的内存屏障来约束重排序。这三个特性互相交织JMM就是围绕它们设计出一组规则。happens-before原则本质上是给程序员一个判断可见性和有序性的依据它用“只要A happens-before B那么A的操作对B可见”这样一句话把复杂的内存屏障细节封装住了。1.3 JMM的定位规范抽象而不是具体的堆栈模型JMM是一种抽象的内存模型它定义的是“线程-主内存-工作内存”三者之间的交互规则。这里说的主内存和工作内存并不是JVM堆内存那种物理概念而是JMM为了描述可见性规则而抽象出的逻辑概念。一个Java线程对应一个工作内存的抽象里面保存了线程使用到的变量副本所有线程共享主内存里面放着变量的“正规军”值。写并发代码时默认情况下的行为是线程把变量读入自己的工作内存操作什么时候把修改同步回主内存是不确定的由JVM和底层硬件共同决定。有人会问“那我平时写并发代码不也经常跑得挺好吗”因为很多场景下你碰巧没有遇到问题——线程启动时、锁释放时、Thread.join时这些点都有隐式的同步边界覆盖掉了一部分问题。但一旦代码并发度高、长跑、压测数据不一致的问题就会浮现出来。所以JMM就是那个让你能真正预测并发程序行为的“游戏规则说明书”。不遵守它你的代码可能今天不出错明天一出错就是线上事故。2. JMM和JVM内存模型的区别以及名字混淆的根源2.1 两者的定义范围和关注点完全不同JVM内存模型JVM Memory Model / Runtime Data Area描述的是JVM运行时把内存划分成哪些区域程序计数器Program Counter Register、虚拟机栈JVM Stack、本地方法栈Native Method Stack、堆Heap、方法区Method Area在JDK 8以后实现为Metaspace。它解决的是对象实例、类元数据、方法调用帧这些数据放在哪里生命周期怎么管理以及垃圾回收作用在哪些区域的问题。JMM关心的是多线程程序里的数据可见性和重排序问题。它跟对象存放在堆还是栈没有关系。JMM的“主内存”并不仅仅指JVM堆而是涵盖了所有共享变量的存储位置通常在堆上但也可以是静态字段、数组元素等JMM的“工作内存”也不是虚拟机栈这种物理区域它只是JMM规范里的抽象概念实际对应的是CPU缓存、写缓冲区、寄存器等高速存储结构的组合。2.2 为什么这两个概念会被混淆混淆的根源有几个。第一两者都叫“memory model”中文都翻译成“内存模型”这个译名本身就有歧义。第二很多初学资料在介绍JVM内存区域时顺手说了“Java内存模型”读者以为堆栈就是Java内存模型于是把JMM等同于JVM内存划分。第三面试中这两者经常一起出现如果面试者只背了JVM区域的名称没真正看过JMM规范很容易张冠李戴。我在实际中遇到的典型场景是面试者能把堆、栈、方法区分得清清楚楚但一问“volatile为什么能保证可见性”就开始含糊其辞。反过来也有聊JMM的happens-before规则头头是道但问Metaspace存什么就懵了。这两个概念确实都需要掌握但不是一回事。2.3 掌握准确概念的一个辅助记忆方法用一个类比来辅助区分。JVM内存模型像是办公室的物理布局图——哪个房间是工位栈哪个房间是仓库堆哪个房间是图书室方法区这决定了大家工作和存放物品的位置。JMM则像是办公室的沟通规则——大家说话的声音、写便签的传播范围、更新的公告栏多久刷一次这决定了你贴的通知变量修改能不能被其他同事线程及时看见以及会不会因为纸条传递顺序错乱而导致误解。这个类比不算完全严谨但能帮助你在头脑里快速建立边界。碰到“Java内存模型”这个词时先看上下文问的是存储布局还是并发语义就大致知道它指的是JVM Memory Model还是JMM。3. JMM的核心机制拆解3.1 主内存与工作内存的交互规则JMM规定所有变量都存储在主内存中Main Memory每个线程拥有自己的工作内存Working Memory。线程对变量的所有操作都必须在工作内存中进行不能直接读写主内存中的变量。线程之间的共享变量值传递必须通过主内存来完成。这套模型类比成协作场景就是每个员工线程办公桌上有一份共享台账共享变量的复印件员工只能改自己桌上的复印件改完必须放回公共档案柜主内存其他员工下一次从档案柜拿新的复印件才能看到改动。如果某个员工一直不把复印件放回档案柜或者放着旧复印件不更新大家看到的数据自然不一致。JMM还定义了8种内存交互操作JDK 9之后有新的访问模式描述但核心逻辑不变lock、unlock、read、load、use、assign、store、write。其中read和load、store和write是成对出现的。整个流程是从主内存read一个变量到工作内存再load进工作内存的变量副本线程对变量进行操作时use变量副本的值运算后assign赋值回变量副本需要同步回主内存时store变量副本值再write到主内存。lock和unlock则是加锁和释放锁的粒度为变量级别的显式同步。这些操作并不是对程序员直接开放的而是由JVM在合适的时机触发。你写代码时不会调用read/load来同步变量但这些语义约束定义了JVM和CPU可以重排序的边界。3.2 指令重排序与内存屏障JMM允许编译器、处理器对指令序列进行重排序只要不改变单线程执行的语义as-if-serial语义。这意味着在单线程下看起来很自然的执行顺序在多线程并发下可能产生不可预期的交错。基于这个原因JMM规定了哪些重排序是允许的、哪些是不允许的并通过内存屏障指令来阻止非法的重排序。内存屏障Memory Barrier是一类CPU指令主要分为四类LoadLoad屏障禁止上面的读操作和下面的读操作重排序、StoreStore屏障禁止上面的写操作和下面的写操作重排序、LoadStore屏障禁止上面的读操作和下面的写操作重排序、StoreLoad屏障禁止上面的写操作和下面的读操作重排序同时要求写操作先被其他处理器感知。JVM在生成指令时会在合适的位置插入内存屏障来限制重排序。比如volatile读之后会插入LoadLoad屏障和LoadStore屏障保证volatile读不会被后面任何普通读写操作重排序到前面volatile写之前会插入StoreStore屏障保证volatile写之前的普通写入不会被重排序到volatile写之后volatile写之后会插入StoreLoad屏障保证volatile写不会被后面的普通读操作重排序到前面。这里有个细节x86处理器本身的内存模型较强TSO模型对于LoadLoad、StoreStore、LoadStore屏障基本是天然满足的所以x86上JVM实际需要的屏障主要是StoreLoad屏障。但在ARM、PowerPC等弱内存模型架构上所有屏障都需要真实插入。这也是为什么同一套Java代码在不同硬件上表现不同在没有正确同步的情况下会出现诡异的并发Bug。3.3 volatile、synchronized、final分别如何对接JMMvolatile是JMM中最轻量的同步机制。它的语义是对一个volatile变量的写操作会立即刷新到主内存对一个volatile变量的读操作会从主内存重新读取最新的值。同时JMM禁止了volatile读写前后的重排序。这样volatile就提供了可见性保证但不提供原子性保证。换句话说用volatile修饰一个int变量单线程内多次自增还是会有并发问题因为自增是读-改-写三步不满足原子性。synchronized依赖JMM中的锁规则。加锁时JMM要求清空工作内存强制从主内存重新读取最新值释放锁时JMM要求把工作内存中的修改刷新回主内存。这样就形成了“线程A解锁之前的所有写操作对于线程B加锁之后的读操作都是可见的”这个关键结论。synchronized同时保证原子性、可见性和有序性但代价是阻塞和上下文切换。final的语义有些特殊。JMM规定在构造函数中初始化的final字段构造完毕之后其他线程可以看到构造后的final字段值前提是没有this引用逸出。这是因为JMM会为final字段的重排序做出限制final字段的写操作不能重排序到构造函数之外确保安全发布final对象。3.4 long和double的特殊非原子性Java语言规范规定对于long和double这种64位类型在32位JVM上可能被当作两个32位操作处理这就导致“半个字写”问题——另一个线程可能读到高32位是新值、低32位是旧值的混合状态。JMM的处理方式是如果long/double变量没有用volatile修饰不保证读写的原子性一旦用volatile修饰则必须保证原子性。在64位JVM上绝大多数实现会保证long和double引用的原子性但JMM规范并未强制要求非volatile的64位类型一定原子。所以为了跨平台的安全共享的long/double变量建议要么加volatile要么放在锁保护范围内。我见过一个实际案例在32位环境上某日志框架的时间戳字段用了long类型且没有同步压测时出现了时间戳乱跳的情况正是这种半写问题导致的。4. happens-before规则JMM给程序员的最实用武器4.1 八条规则逐条解读happens-before是JMM定义的一种偏序关系。如果操作A happens-before操作B那么A的结果对B可见且A的执行顺序在B之前从内存可见性角度。需要强调的是happens-before并不是时间顺序而是可见性顺序。八条规则如下程序次序规则一个线程内按代码顺序前面的操作happens-before后面的操作。这个规则说的是单线程内的控制流顺序不考虑重排序的情况因为重排序不能改变单线程的语义。管程锁定规则对一个锁的解锁unlockhappens-before后续对这个锁的加锁lock。重点在于“后续”是同一把锁上的后续加锁。这保证了锁内保护的所有写操作对竞争同一把锁的线程可见。volatile变量规则对一个volatile变量的写操作happens-before后续对这个volatile变量的读操作。注意是“后续”即读操作发生的时间点必须晚于写操作且读到的值必须是最新写入的值。线程启动规则对线程的start()调用happens-before该线程中任何一个动作。这意味着所有在start()之前写入的共享变量在新线程开始执行后都能看到。线程终止规则线程中的所有动作happens-before该线程的终止检测也就是其他线程通过Thread.join()返回或通过Thread.isAlive()检测到线程已终止时该线程中的所有操作结果对其他线程可见。线程中断规则对线程interrupt()方法的调用happens-before被中断线程检测到中断事件抛出InterruptedException或调用isInterrupted()发现状态置位。对象终结规则一个对象的构造函数结束happens-before它的finalizer开始执行。传递性如果A happens-before B且B happens-before C则A happens-before C。这是最常用的一条推论规则它允许我们通过多条规则链推导出跨线程的可见性。4.2 用规则推导一个实际的可见性场景拿生产者-消费者场景举个例子。// 共享变量 static boolean ready false; static int num 0; // 线程A num 42; // 操作1 ready true; // 操作2volatile写 // 线程B while (!ready) { // 操作3volatile读 // 自旋等待 } System.out.println(num); // 操作4按照规则操作1写入num是普通写操作2是volatile写操作3是volatile读。volatile写规则保证操作2 happens-before操作3因此一旦线程B看到ready为true它必然能看到num的最新值42。程序次序规则保证操作1 happens-before操作2传递性保证操作1的结果对操作3之后的读可见。所以最终打印的num必然是42不会是0。如果ready不是volatile这段代码就是有问题的即使线程B看到了readytrue它也可能读到num的旧值0因为普通写没有跨线程的可见性保证。这就是volatile最典型的使用场景——作为状态标志同时通过它传递其他非volatile变量的写入结果。4.3 happens-before和最直接的内存屏障之间如何对应happens-before规则是JMM给程序员看的抽象接口内存屏障是物理实现。编写Java代码时我们依据happens-before规则判断对错不需要关心具体插了哪些屏障。但排查汇编级别的并发问题时了解两层之间的对应关系很有帮助。比如volatile写后面跟一个StoreLoad屏障它会清空写缓冲并等待前面的写操作全部刷新到主内存volatile读后面跟LoadLoad屏障则确保后面的读操作不能提前越过volatile读。实际编码中绝大多数时候用happens-before做推理就够了。但如果看到“重排序导致的问题”“缓存未失效”这类字眼那就要往内存屏障的方向想。我曾经在排查一个问题时发现某个高频路径的普通字段读到了一个极其陈旧的值时间戳落后了几秒后来分析是因为所在线程长时间没有进行任何可能触发同步的操作没有加锁、没有volatile读写、没有Thread.yieldCPU缓存中的旧值一直没有失效。这种问题用happens-before推理最直观因为没有建立任何跨线程的happens-before关系所以读到旧值是允许的。5. 结合锁、volatile、final来理解JMM的实际使用5.1 单例模式DCL加volatile的经典案例双重检查锁DCL是面试里最常考的模式也是理解volatile和synchronized在JMM中如何协同的好例子。public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { // 第一次检查 synchronized (Singleton.class) { // 加锁 if (instance null) { // 第二次检查 instance new Singleton(); // 创建实例 } } } return instance; } }问题是instance为什么必须加volatile关键在于new Singleton()这个操作不是原子操作。它大致包含三步分配内存空间、调用构造器初始化对象、把instance引用指向这块内存。在没有volatile的情况下JVM和CPU可能把第3步重排序到第2步之前。此时另一个线程执行第一次检查发现instance不为null直接返回了这个还没初始化完成的对象使用时就会出现空指针或部分初始化状态。加上volatile之后JMM禁止了volatile写instance赋值前面的普通写构造器里的初始化重排序到volatile写之后也就是说instance引用被发布出去之前对象一定已经完整初始化。这就杜绝了“部分构造对象被提前暴露”的问题。我遇到一些团队说他们不用DCL改成静态内部类方式来实现单例避免记忆volatile语义的负担。确实静态内部类由classloader保证线程安全更简单可靠。但理解DCL的volatile问题仍然是检验JMM掌握程度的试金石。5.2 锁内部的可见性保障和锁释放的细节synchronized的可见性边界非常清晰进入synchronized块时该线程的工作内存会失效后续读操作必须从主内存读取退出synchronized块时所有修改会刷新到主内存。在此基础上如果两个线程竞争同一把锁那么解锁和加锁之间就建立了一个happens-before关系。Lock接口实现如ReentrantLock、ReentrantReadWriteLock在JMM语义上等价于synchronized。通过AQSAbstractQueuedSynchronizer内部的volatile state变量来传播锁状态保证释放锁的线程对共享变量的修改对获取锁的线程可见。要注意的一个坑是锁的粒度如果你用synchronized保护的是A变量但另一个线程修改B变量时也用了同一把锁那没问题但如果你在修改B变量时用的不是同一把锁锁跨线程的可见性保障就完全不成立。JMM不保证任何“隐式的、未约定的”同步。很多时候并发Bug不是没有加锁而是加错了锁、锁对象不一致导致happens-before关系没有建立。5.3 final的可见性保障和this引用逸出的风险final字段在并发下有额外的编译保障。JMM要求只要对象正确构造构造函数没有把this引用泄露出去并且构造完成后不可变那么其他线程通过安全的方式获得该对象引用比如通过static字段或其他同步机制发布就一定能看到所有final字段的最终值。这是因为JVM会将final字段的构造函数写入与构造函数之外的读操作做排序限制。this引用逸出是final机制失效的经典场景。比如在构造函数里启动一个线程线程体中读取这个对象的字段此时这个对象还没构造完成线程拿到的可能是部分初始化的对象。即使字段是final也无法保证该线程读到正确值因为this引用在构造函数完成前就泄露了。正确的做法是在构造函数结束后再通过专门的方法去启动依赖该对象的线程。5.4 ThreadLocal与线程封闭的可见性ThreadLocal本身并不直接依赖JMM的跨线程可见性它把数据绑定在某个线程内部从根本上避免了共享。但要注意的是ThreadLocal中的值实际上是存储在当前线程的ThreadLocalMap中的这个Map的Entry是WeakReference类型的。如果父线程创建了ThreadLocal值子线程通过InheritableThreadLocal来获取那么在子线程创建时父线程的值会一次性拷贝到子线程后续父线程的值变了子线程是看不到的。老实说这个机制倒没有什么JMM的坑更多是生命周期管理的问题比如线程池复用时ThreadLocal残留导致数据混乱。但从JMM角度理解线程封闭价值在于完全不共享的变量自然不涉及可见性、原子性、有序性问题。这是并发编程“最简单”的解题思路——不要共享。6. 一个真实并发Bug的排查过程来看JMM在实战中的应用6.1 症状与初步猜测我曾经参与过一个订单处理系统的问题排查。现象很诡异在流量高峰时某些订单状态在数据库里被更新成功但应用内存中的订单对象状态长时间没有刷新导致后续对同一订单的操作读到了“过期”状态。系统用了多线程并发处理的架构一个线程负责从MQ拉到订单消息并更新内存Map另一个线程从该Map读取订单状态做后续业务。当时我们第一反应是数据库缓存问题但排查后发现数据库始终是最新的问题出在应用内存里。继续看代码发现更新内存Map的线程没有加锁读取的线程也没有加锁Map用的是ConcurrentHashMap。单个Key的put和get是原子的但问题在于“订单状态”和“订单实体”是两个字段更新流程是先构建新的订单对象再调用map.put()这个put虽然原子但是读取线程读到的可能是旧版本的对象。6.2 用happens-before推导定位根因进一步分析发现更新流程里除了map.put()之外没有任何同步操作没有锁、没有volatile、没有AtomicReference。读取流程同样只有map.get()。这两个线程之间没有建立任何happens-before关系。虽然ConcurrentHashMap保证单个操作的线程安全但“线程A更新Map的可见性”和“线程B读取Map的可见性”之间并没有强制同步边界。换句话说ConcurrentHashMap只保证它是线程安全的容器不会保证里面的元素引用变化能被其他线程立即看到。所以读取线程可能长时间读到自己工作内存里的旧对象甚至旧对象的字段状态这就是可见性问题。定位后我们做了两个修复第一把持有订单对象的引用改成AtomicReference通过CAS更新利用volatile的写读语义建立happens-before关系让写线程的最新对象对读线程可见第二在读取侧增加请求级别的校验如果本地副本落后主动从数据库重新加载最新状态。6.3 这个案例提炼出的经验从这次排查中学到的东西我总结成了几条经验在日常写并发代码时非常有用。第一线程安全不等于可见性。一个类标榜线程安全线程安全的容器、线程安全的计数器只意味着它自身的内部状态不会被多线程搞坏但不意味着跨线程的“新值可见”是隐式成立的。要传递新值还是得依靠happens-before规则。第二写代码时时时刻刻问自己读线程和写线程之间建立了哪条happens-before关系如果答不上来那你就是在赌并发不出错。JMM不允许你运气好就当作正确。第三同步工具的选择顺序应该是优先无共享线程封闭、不可变对象其次用现成的并发工具ConcurrentHashMap、Atomic类、阻塞队列、显式锁再考虑裸的volatile和synchronized。越是底层的手段越要求你对JMM有深入理解。第四线上疑难并发问题建议给JVM加参数-XX:PrintAssembly或者使用JITWatch工具看看热点代码生成的汇编指令搜索lfence、mfence这类屏障指令能直观看到JVM在哪里插了屏障。这不是日常必需技能但在排查强实时性系统的可见性问题时能帮上大忙。7. 提到JMM时大家常犯的几个理解误区和补充建议7.1 误区volatile保证原子性这是最普遍的错误认知。volatile只保证可见性和有序性不保证复合操作的原子性。例如多个线程同时对一个volatile int执行value最终结果仍然会小于期望值。正确做法是用AtomicInteger或者synchronized保护自增操作。7.2 误区synchronized一定会导致严重的性能损耗JVM对synchronized做了很多优化偏向锁、轻量级锁、锁升级、锁消除、锁粗化。在低竞争甚至无竞争场景下synchronized的开销非常低。很多人无脑用ConcurrentHashMap替代HashTable却不知道HashTable的全局锁在高并发读多写少时也不见得差因为ConcurrentHashMap在JDK 8之后用CASsynchronized锁桶但读路径也是volatile读开销并不低。JMM层面的知识能帮助你判断同步成本和正确性的权衡。7.3 误区64位JVM上long/double读写天然原子JMM规范并没有做出这种保证。虽然绝大多数主流的64位JVM实现会把64位读写原子化尤其在x86 64位环境下但如果你依赖这一行为做跨平台并发就是把自己的正确性建立在实现细节上。老老实实加volatile或者锁保护。同理任何“我觉得某个JVM行为应该如此”的想法都是并发编程的大敌。7.4 关于性能的度量同步不等于慢但屏障不是免费的volatile读写会插入内存屏障影响指令重排序和CPU流水线效率。在高频路径上无节制地使用volatile可能让性能退化明显。但反过来不同步导致的并发错误带来的损失远超性能损失。平衡点在于确认自己的并发场景真的需要同步然后选择粒度最小的同步手段。举个例子如果共享变量是配置项初始化后不再变化那么把它设计成不可变对象再发布比如通过AtomicReference一次性赋值后续读操作就不需要加锁或加volatile性能最佳。如果共享变量频繁变化且对实时性要求极高那就需要认真设计内存屏障的布局了。7.5 JMM的学习路径建议我一直觉得JMM不应该靠背概念学而是靠场景驱动。你先动手写一个明显有可见性问题的多线程Demo跑起来观察不同步时的表现然后加上volatile、synchronized再观察变化然后再通过happens-before规则推导其他工具比如ConcurrentHashMap、CountDownLatch、CyclicBarrier的可见性保证。这样建立起来的知识是活的而不是死记硬背。对于准备面试的同学我的建议是把happens-before八条规则记下来并理解把volatile和synchronized的JMM语义吃透把DCL单例的volatile问题讲清楚基本就能应对大部分并发底层的提问了。但更重要的是把这些知识用到实际代码里。回到最开始的问题JMM和JVM内存模型的区别。JVM内存模型是你工作的场所JMM是你在场所里进行多人协作时的沟通规则。把场所和规则分开理解并发编程的很多困惑就自然消散了。希望这篇能帮你把JMM这层底层语义梳理清楚。