Loading [MathJax]/jax/output/CommonHTML/config.js
前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >Java并发编程系列-(7) Java线程安全

Java并发编程系列-(7) Java线程安全

作者头像
码老思
发布于 2023-10-19 12:07:40
发布于 2023-10-19 12:07:40
27000
代码可运行
举报
文章被收录于专栏:后端精进之路后端精进之路
运行总次数:0
代码可运行

7. 线程安全

7.1 线程安全的定义

如果多线程下使用这个类,不过多线程如何使用和调度这个类,这个类总是表示出正确的行为,这个类就是线程安全的。

类的线程安全表现为:

  • 操作的原子性
  • 内存的可见性

不做正确的同步,在多个线程之间共享状态的时候,就会出现线程不安全。

7.2 如何保证线程安全

栈封闭

所有的变量都是在方法内部声明的,这些变量都处于栈封闭状态。

比如下面的例子,a和b都是在方法内部定义的,无法被外部线程所访问,当方法结束后,栈内存被回收,所以是线程安全的。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
void fun(){
    int a = 1;
    int b= 2// do something
}
无状态

没有任何成员变量的类,就叫无状态的类,这种类不存在共享的资源,显然是安全的。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
public class StatelessClass {
	
	public int service(int a,int b) {
		return a*b;
	}
}
不可变的类

让状态不可变,两种方式:

  1. 加final关键字。对于一个类,所有的成员变量应该是私有的,并且可能的情况下,所有的成员变量应该加上final关键字。需要注意如果成员变量又是一个对象时,这个对象所对应的类也要是不可变,才能保证整个类是不可变的。
  2. 根本就不提供任何可供修改成员变量的地方,同时成员变量也不作为方法的返回值。

下面例子中的,成员变量都是final并且也没有提供给外部修改变量的地方,因此是线程安全的。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
public class ImmutableFinal {
	
	private final int a;
	private final int b;
	
	public ImmutableFinal(int a, int b) {
		super();
		this.a = a;
		this.b = b;
	}

	public int getA() {
		return a;
	}

	public int getB() {
		return b;
	}
}

下面的例子中,虽然User成员变量是final的,无法修改引用。但是外部依然可以通过getUser获取到User的引用之后,修改User对象。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
public class ImmutableFinalRef {
	
	private final int a;
	private final int b;
	private final User user;//这里就不能保证线程安全了
	
	public ImmutableFinalRef(int a, int b) {
		super();
		this.a = a;
		this.b = b;
		this.user = new User();
	}

	public int getA() {
		return a;
	}

	public int getB() {
		return b;
	}
	
	public User getUser() {
		return user;
	}

	public static class User{
		private int age;

		public User(int age) {
			super();
			this.age = age;
		}

		public int getAge() {
			return age;
		}

		public void setAge(int age) {
			this.age = age;
		}
		
	}
	
	public static void main(String[] args) {
		ImmutableFinalRef ref = new ImmutableFinalRef(12,23);
		User u = ref.getUser();
		//u.setAge(35);
	}
}
volatile

volitile在ConcurrentHashMap等并发容器中都有使用,用于保证变量的可见性。最适合一个线程写,多个线程读的情景。

加锁和CAS

加锁可以显示地控制线程对类的访问,使用正确可以保证线程安全。

CAS操作通过不断的循环对比,试图对目标对象进行修改,也能保证线程安全。广泛用于JDK并发容器的实现中。

安全的发布

类中持有的成员变量,特别是对象的引用,如果这个成员对象不是线程安全的,通过get等方法发布出去,会造成这个成员对象本身持有的数据在多线程下不正确的修改,从而造成整个类线程不安全的问题。

ThreadLocal

这个类能使线程中的某个值与保存值的对象关联起来。ThreadLocal提供了get与set等访问接口与方法,这些方法为使用该变量的每个线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。

当某个线程初次调用ThreadLocal.get方法时,就会调用initialValue来获取初始值。从概念上讲,你可以将ThreadLocal视为包含了Map<Thread, T>对象,其中保存了特定于该线程的值,但ThreadLocal的实现并非如此,这些特定的值保存在Thread对象中,当线程终止后,这些值会作为垃圾回收。

7.3 死锁

定义

死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。

死锁的根本成因:获取锁的顺序不一致导致。

可以利用下面的示意图帮助理解:

死锁范例

下面的程序中,两个线程分别获取到了first和second,然后相互等待,产生了死锁。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
public class DeadLockSample extends Thread {
    private String first;
    private String second;
    public DeadLockSample(String name, String first, String second) {
        super(name);
        this.first = first;
        this.second = second;
    }
    public void run() {
        synchronized (first) {
            System.out.println(this.getName() + " obtained: " + first);
            try {
                Thread.sleep(1000L);
                synchronized(second) {
                    System.out.println(this.getName() + " obtained: " + second);
                }
            } catch (InterruptedException e) {
                // Do nothing
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        String lockA = "lockA";
        String lockB = "lockB";
        DeadLockSample t1 = new DeadLockSample("Thread1", lockA, lockB);
        DeadLockSample t2 = new DeadLockSample("Thread2", lockB, lockA);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }
}
定位和解决死锁

Debug时可以使用 jps 或者系统的 ps 命令、任务管理器等工具,确定进程 ID。其次,调用 jstack 获取线程栈,jstack your_pid. jstack 本身也会把类似的简单死锁抽取出来,直接打印出来。

如果是开发自己的管理工具,需要用更加程序化的方式扫描服务进程、定位死锁,可以考虑使用 Java 提供的标准管理 API,ThreadMXBean,其直接就提供 findDeadlockedThreads() 方法用于定位,上面的例子中用到了这个方法。

怎么预防死锁?
  1. 如果可能的话,尽量避免使用多个锁,并且只有需要时才持有锁。
  2. 如果必须使用多个锁,尽量设计好锁的获取顺序。如果对于两个线程的情况,可以参考如下的实现:

在实现转账的类时,为了防止由于相互转账导致的死锁,下面的实现中,通过对比账户的hash值来确定获取锁的顺序。当两者的hash值相等时,虽然这种情况非常少见,使用了单独的锁,来控制两个线程的访问顺序。

注意System.identityHashCode()是JDK自带的hash实现,在绝大部分情况下,保证了对象hash值的唯一性。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
public class SafeOperate implements ITransfer {
	private static Object tieLock = new Object();//加时赛锁

    @Override
    public void transfer(UserAccount from, UserAccount to, int amount)
            throws InterruptedException {
    	
    	int fromHash = System.identityHashCode(from);
    	int toHash = System.identityHashCode(to);
    	//先锁hash小的那个
    	if(fromHash<toHash) {
            synchronized (from){
                synchronized (to){
                    System.out.println(Thread.currentThread().getName()
                    		+" get"+to.getName());
                    from.flyMoney(amount);
                    to.addMoney(amount);
                }
            }    		
    	}else if(toHash<fromHash) {
            synchronized (to){
                Thread.sleep(100);
                synchronized (from){
                    from.flyMoney(amount);
                    to.addMoney(amount);
                }
            }    		
    	}else {//解决hash冲突的方法
    		synchronized (tieLock) {
				synchronized (from) {
					synchronized (to) {
	                    from.flyMoney(amount);
	                    to.addMoney(amount);						
					}
				}
			}
    	}
    	
    }
}
  1. 使用带超时的方法,为程序带来更多可控性。

类似 Object.wait(…) 或者 CountDownLatch.await(…),都支持所谓的 timed_wait,我们完全可以就不假定该锁一定会获得,指定超时时间,并为无法得到锁时准备退出逻辑。

  1. 使用Lock实现(推荐)

并发 Lock 实现,如 ReentrantLock 还支持非阻塞式的获取锁操作 tryLock(),这是一个插队行为(barging),并不在乎等待的公平性,如果执行时对象恰好没有被独占,则直接获取锁。

标准的使用流程如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
while(true) {
   if(A.getLock().tryLock()) {
    try {
    	if(B.getLock().tryLock()) {
    		try {
    		  //两把锁都拿到了,开始执行业务代码
    	           break;
    		}finally {
    		  B.getLock().unlock();
    	    }
       }
    }finally {
    	A.getLock().unlock();
    }
  }
  // 非常重要,sleep随机的时间,以防两个线程谦让,产生长时间的等待,也就是活锁
  SleepTools.ms(r.nextInt(10));
}

7.4 活锁/线程饥饿/无锁

活锁

活锁恰恰与死锁相反,死锁是大家都拿不到资源都占用着对方的资源,而活锁是拿到资源却又相互释放不执行。当多线程中出现了相互谦让,都主动将资源释放给别的线程使用,这样这个资源在多个线程之间跳动而又得不到执行,这就是活锁。

在上面解决死锁的第四个方案中,为了避免活锁,采用了随机休眠的机制。

线程饥饿

线程执行中有线程优先级,优先级高的线程能够插队并优先执行,这样如果优先级高的线程一直抢占优先级低线程的资源,导致低优先级线程无法得到执行,这就是饥饿。当然还有一种饥饿的情况,一个线程一直占着一个资源不放而导致其他线程得不到执行,与死锁不同的是饥饿在以后一段时间内还是能够得到执行的,如那个占用资源的线程结束了并释放了资源。

无锁

对于并发控制而言,锁是一种悲观的策略,它总是假设每一次的临界区操作会产生冲突,由此,如果有多个线程同时需要访问临界区资源,则宁可牺牲资源让线程进行等待。

无锁是一种乐观的策略,它假设对资源的访问是没有冲突的。既然没有冲突,自然不需要等待,所以所有的线程都可以在不停顿地状态下持续执行。当遇到冲突,则使用CAS来检测线程冲突,如果发现冲突,则重试直到没有冲突为止。

CAS算法的过程是,它包含三个参数CAS(V,E,N),V表示要更新的变量,E表示预期值,N表示新值。仅当V值等于E值时,才将V的值设置为N,如果V值和E值不同,说明已经有其他线程做了更新,则当前线程什么都不做。使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。

7.5 影响性能的因素

  • 上下文切换:一般花费5000-10000个时钟周期,几微秒
  • 内存同步:加锁等操作,增加额外的指令执行时间
  • 阻塞:挂起线程,包括额外的上下文切换

7.6 锁性能优化

减少锁的持有时间

减少锁的持有时间有助于降低锁冲突的可能性,进而提升系统的并发能力。

减小锁粒度

这种技术的典型使用场景就是ConcurrentHashMap。

对于HashMap来说,最重要的两个方法就是get() 和put(),一种最自然的想法就是对整个HashMap加锁,必然可以得到一个线程安全的对象.但是这样做,我们就认为加锁粒度太大.对于ConcurrentHashMap,它内部进一步细分了若干个小的hashMap,称之为段(SEGMENT).默认的情况下,一个ConcurrentHashMap被进一步细分为16个段

如果需要在ConcurrentHashMap中增加一个新的表项,并不是整个HashMap加锁,而是首先根据hashcode得到该表项应该被存放到哪个段中,然后对该段加锁,并完成put()操作.在多线程环境中,如果多个线程同时进行put()操作,只要被加入的表项不存放在同一个段中,则线程间便可以做到真正的并行。

读写分离锁来替换独占锁

在读多写少的场合,使用读写锁可以有效提升系统的并发能力

锁分离

如果将读写锁的思想进一步的延伸,就是锁分离.读写锁根据读写锁操作功能上的不同,进行了有效的锁分离.使用类似的思想,也可以对独占锁进行分离.

以LinkedBlockingQueue为例,take函数和put函数分别实现了冲队列取和往队列加数据,虽然两个方法都对队列进项了修改,但是LinkedBlockingQueue是基于链表的所以一个操作的是头,一个是队列尾端,从理论情况下将并不冲突

如果使用独占锁则take和put就不能完成真正的并发,所以jdk并没有才用这种方式取而代之的是两把不同的锁分离了put和take的操作

锁粗化

凡事都有一个度,如果对同一个锁不停地进行请求,同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化。

为此,虚拟机在遇到一连串连续地对同一锁不断进行请求和释放的操作时,便会把所有的锁操作整合成对锁的一次请求,从而减少对锁的请求同步次数,这个操作叫做锁的粗化.

7.7 实现线程安全的单例模式

懒汉式
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
public static synchronized Singleton getInstance() {
    if (instance == null) {
        instance = new Singleton();
    }
    return instance;
}

线程安全,并且解决了多实例的问题,但是它并不高效。因为在任何时候只能有一个线程调用 getInstance() 方法。

双重检验锁
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
public class Singleton {
    private static volatile Singleton singleton = null;
    private Singleton() {
    }
    public static Singleton getSingleton() {
        if (singleton == null) { // 尽量避免重复进入同步块
            synchronized (Singleton.class) { // 同步.class,意味着对同步类方法调用
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}
  • volatile 能够提供可见性,以及保证 getInstance 返回的是初始化完全的对象。
  • 在同步之前进行 null 检查,以尽量避免进入相对昂贵的同步块。
  • 直接在 class 级别进行同步,保证线程安全的类方法调用。

在这段代码中,争论较多的是 volatile 修饰静态变量,当 Singleton 类本身有多个成员变量时,需要保证初始化过程完成后,才能被 get 到。 在现代 Java 中,内存排序模型(JMM)已经非常完善,通过 volatile 的 write 或者 read,能保证所谓的 happen-before,也就是避免常被提到的指令重排。换句话说,构造对象的 store 指令能够被保证一定在 volatile read 之前。

饿汉式

这种方法非常简单,因为单例的实例被声明成 static 和 final 变量了,在第一次加载类到内存中时就会初始化,所以创建实例本身是线程安全的。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
public class Singleton{
    //类加载时就初始化
    private static final Singleton instance = new Singleton();
    
    private Singleton(){}

    public static Singleton getInstance(){
        return instance;
    }
}
静态内部类(推荐)
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
public class Singleton {  
    private static class SingletonHolder {  
        private static final Singleton INSTANCE = new Singleton();  
    }  
    private Singleton (){}  
    public static final Singleton getInstance() {  
        return SingletonHolder.INSTANCE; 
    }  
}

静态内部类是在被调用时才会被加载,因此它是懒汉式的。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2019-12-23,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
暂无评论
推荐阅读
编辑精选文章
换一批
Nebula Graph 源码解读系列 | Vol.03 Planner 的实现
上篇我们讲到 Validator 会将由 Parser 生成的抽象语法树(AST)转化为执行计划,这次,我们来讲下执行计划是如何生成的。
NebulaGraph
2021/09/25
6390
Presto系列 | Presto基本介绍
Presto是一款Facebook开源的MPP架构的OLAP查询引擎,可针对不同数据源执行大容量数据集的一款分布式SQL执行引擎。因为工作中接触到Presto,研究它对理解SQL Parser、常见算子的实现(如SQL中table scan,join,aggregation)、资源管理与调度、查询优化(如向量化执行、动态代码生成)、大数据下各个组件为何适用不同场景等等都有帮助。我希望通过这个系列可以了解一条SQL在大数据场景下该如何高效执行。233酱准备不定时持续更新这个系列,本文主要从Presto的使用举例,Presto的应用场景、Presto的基本概念三个部分来初步介绍Presto。
Monica2333
2020/09/24
4.5K0
Presto系列 | Presto基本介绍
聊聊分布式 SQL 数据库Doris(五)
Ryan_OVO
2023/11/28
3110
聊聊分布式 SQL 数据库Doris(五)
查看SQL执行计划的方法及优劣
作者 | 胡佳伟:云和恩墨技术工程师,有多年数据库优化经验,在一线执行过多个包括通信、保险等行业的优化项目。
数据和云
2018/07/27
1.2K0
查看SQL执行计划的方法及优劣
Spark SQL底层执行流程详解(好文收藏)
一、Apache Spark 二、Spark SQL发展历程 三、Spark SQL底层执行原理 四、Catalyst 的两大优化
五分钟学大数据
2022/05/22
5K0
Spark SQL底层执行流程详解(好文收藏)
Antlr4实战:统一SQL路由多引擎
ANTLR是一款功能强大的语法分析器生成器,可用来读取、处理、执行和转换结构化文本或二进制文件。它被广泛应用于学术界和工业界构建各种语言、工具和框架。Antlr在Hadoop整个生态系统应用较为广泛,如Hive 词法文件是Antlr3写的;Presto词法文件也Antlr4实现的;SparkSQL词法文件是用Presto的词法文件改写的;还有HBase的访问客户端Phoenix也用Antlr工具进行SQL解析的等等。
用户7600169
2022/04/25
10.3K1
Antlr4实战:统一SQL路由多引擎
Nebula Graph 源码解读系列 | Vol.02 详解 Validator
Nebula Graph Query Engine 主要分为四个模块,分别是 Parser、Validator、Optimizer 和 Executor。
NebulaGraph
2021/09/24
5600
Oracle固定SQL的执行计划(二)—SPM
之前写了一篇文章介绍的是用SQL Profile来调整、稳定目标SQL的执行计划,即使无法修改目标SQL的SQL文本。但SQL Profile实际上只是一种亡羊补牢、被动的技术手段,应用在那些执行计划已经发生了不好的变更的SQL上,即当我们发现这些SQL的执行计划已经出了问题时通过创建SQL Profile来纠正、稳定这些SQL的执行计划。即便通过创建SQL Profile解决了目标SQL执行计划变更的问题,依然不能保证系统后续执行的SQL的执行计划就不再发生不好的变更。这种不确定性会给Oracle数据库大版本升级(比如从Oracle 10g升级到Oracle 11g)带来一系列的麻烦,因为不清楚升级之后原先系统中哪些SQL的执行计划会发生不好的变更。
星哥玩云
2022/08/13
1.3K0
Oracle固定SQL的执行计划(二)—SPM
TiDB 优化器 | 执行计划管理及实践
在 TiDB 中,优化器的作用至关重要,它决定了 SQL 查询的执行计划,从而直接影响查询性能。尽管 TiDB 优化器采用了代价模型来选择最优执行计划,但由于统计信息、估算误差等因素,优化器并不能保证每次都选中最佳计划。本文深入解析了 TiDB 优化器的执行计划生成过程及其局限性,介绍了如何通过 Hint、SQL Binding、执行计划缓存等技术手段进行执行计划管理,确保查询性能的稳定性和高效性。
PingCAP
2024/12/12
1360
TiDB 优化器 | 执行计划管理及实践
Hive底层原理:explain执行计划详解
不懂hive中的explain,说明hive还没入门,学会explain,能够给我们工作中使用hive带来极大的便利!
五分钟学大数据
2021/02/20
3.6K0
Hive底层原理:explain执行计划详解
Postgresql源码(132)分布式行锁的原理分析
PG中的行锁在上一片中做了分析《Postgresql源码(131)行锁的原理分析》,本篇对分布式PG(PGXL)中的行锁做一些分析。(版本:Postgres-XL 10alpha2)
mingjie
2024/05/24
2090
Postgresql源码(132)分布式行锁的原理分析
SQL Server 执行计划缓存
概述 了解执行计划对数据库性能分析很重要,其中涉及到了语句性能分析与存储,这也是写这篇文章的目的,在了解执行计划之前先要了解一些基础知识,所以文章前面会讲一些概念,学起来会比较枯燥,但是这些基础知识非常重要。 目录 概述 基础概念 怎样缓存执行计划 SQL Server自动删除执行计划 重新编译执行计划 测试 执行计划相关系统视图 手动清空缓存执行计划 测试索引更改对执行计划的影响 测试增加字段对执行计划的影响 总结 基础概念 SQL Server 有一个用于存储执行计划和数据缓冲区
逸鹏
2018/04/11
2K0
SQL Server 执行计划缓存
从真实案例出发,全方位解读 NebulaGraph 中的执行计划
本文整理自 NebulaGraph 核心开发 Yee 在直播《聊聊执行计划这件事》中的主题分享。分享视频参见 B站:https://www.bilibili.com/video/BV1Cu4y1h7gn/
NebulaGraph
2023/11/15
3250
从真实案例出发,全方位解读 NebulaGraph 中的执行计划
盘点:SQL on Hadoop中用到的主要技术
自打Hive出现之后,经过几年的发展,SQL on Hadoop相关的系统已经百花齐放,速度越来越快,功能也越来越齐全。本文并不是要去比较所谓“交互式查询哪家强”,而是试图梳理出一个统一的视角,来看看各家系统有哪些技术上相通之处。
王知无-import_bigdata
2020/06/11
1.4K0
尝尝鲜|Spark 3.1自适应执行计划
每个框架产生都是为了解决一类问题,每个模块的优化也是为了解决一定的场景下的性能瓶颈。浪尖今天分享的关于Spark 3.1之后的自适应执行计划,主要针对以下几个场景,并且有百度率先研发的,不过社区之前一直没有采纳,spark 3.0的预发布版本参数也是不全,到了Spark 3.1的beta版已经可用,浪尖已经完成了测试。
Spark学习技巧
2021/03/05
9160
尝尝鲜|Spark 3.1自适应执行计划
Hive SQL底层执行过程详细剖析(好文收藏)
Hive是什么?Hive 是数据仓库工具,再具体点就是一个 SQL 解析引擎,因为它即不负责存储数据,也不负责计算数据,只负责解析 SQL,记录元数据。
五分钟学大数据
2021/07/07
9.6K0
Hive SQL底层执行过程详细剖析(好文收藏)
Presto 分布式SQL查询引擎及原理分析
Presto是由 Facebook 推出的一个基于Java开发的开源分布式SQL查询引擎,适用于交互式分析查询,数据量支持GB到PB字节。Presto本身并不存储数据,但是可以接入多种数据源,并且支持跨数据源的级联查询。
yuanyi928
2020/05/20
4.9K0
Mongodb执行计划
前面2篇文章讲到分页性能优化相关知识点,但并没有介绍如何找出系统中TOP SQL、对于如何清理SQL缓存执行计划(比如走错执行计划,存在数据倾斜的情况)、Mongo如何针对不同查询语句选择执行计划等相关知识点.
徐靖
2020/08/05
1K0
Mongodb执行计划
Oracle 执行计划查看方法汇总及优劣比较
执行计划是一条 SQL 语句在 Oracle 数据库中的执行过程或访问路径的描述。如下图所示,是一个比较完整的执行计划示意图。
JiekeXu之路
2022/12/07
1.5K0
Oracle 执行计划查看方法汇总及优劣比较
由浅入深了解Presto技术内幕
Presto是专为大数据实时查询计算而设计开发的产品,拥有如下特点: – 多数据源:通过自定义Connector能支持Mysql,Hive,Kafka等多种数据源 – 支持SQL:完全支持ANSI SQL – 扩展性:支持自定义开发Connector和UDF – 混合计算:可以根据需要将开源于不同数据源的多个Catalog进行混合join计算 – 高性能:10倍于Hive的查询性能 – 流水线:基于Pipeline设计,在数据处理过程当中不用等到所有数据都处理完成后再查看结果
大数据真好玩
2020/06/03
3.5K0
相关推荐
Nebula Graph 源码解读系列 | Vol.03 Planner 的实现
更多 >
LV.1
菜菜机器人培训低水平讲师
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
本文部分代码块支持一键运行,欢迎体验
本文部分代码块支持一键运行,欢迎体验