news 2026/9/11 7:22:20

Java方法底层原理:从栈帧到动态代理的完整解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java方法底层原理:从栈帧到动态代理的完整解析

最近在梳理Java基础时,我越来越觉得“方法”这个看似最简单的语法元素,恰恰是很多面试翻车现场的高频考点。不管是大厂面试题里的“八股文”,还是实际开发中遇到的各种诡异bug,追溯到最后通常都能回到方法调用、参数传递、重载重写这些基础原理上。白天写业务代码,晚上研究底层原理,这篇文章我就把对Java方法从语法到底层机制的一些实操心得完整整理出来,希望对正在学Java的朋友、准备跳槽的同行,或者想系统补源码基础的开发者都有参考价值。

1. 方法的本质与内存模型

1.1 方法不只是“代码块”,而是一整套栈帧的生命周期

很多初学者会把方法理解为“一段可以被重复调用的代码”,这种理解没有错,但不足以解释为什么递归会栈溢出、为什么方法调用有开销、为什么并发编程里局部变量是线程安全的。要真正弄懂方法,得从JVM运行时数据区的角度重新看一遍方法调用过程。

在HotSpot虚拟机中,每一次方法调用都会对应一个栈帧(Stack Frame)的创建与销毁。栈帧中主要包含四块核心内容:

  • 局部变量表(Local Variables Array):存放方法参数和方法内定义的局部变量,以槽(Slot)为单位,long和double占两个槽,其他类型占一个槽。
  • 操作数栈(Operand Stack):方法内部进行计算时的临时存储区,比如执行int c = a + b时,会先把a和b压入操作数栈,然后执行加法指令弹出两个数、压入结果。
  • 动态链接(Dynamic Linking):指向运行时常量池中该方法的引用,支撑方法调用时的符号引用解析。
  • 方法出口(Return Address):正常返回或异常返回后,程序需要回到调用处的下一条指令继续执行。

我用一个生活化类比来说明:栈帧就像你做菜时手里拿的一张“菜谱卡片”,上面写着你需要用哪些原料(局部变量表)、当前做到哪一步(操作数栈)、做完之后要回到哪个工序(方法出口)。每调用一个方法,就往任务台上压一张新卡片;方法返回,就把卡片抽掉。卡片叠得太多,任务台就塌了——这就是栈溢出(StackOverflowError)的直观理解。

这个方法调用过程实际上回答了面试中几个高频问题:

  • 为什么局部变量线程安全?因为每个线程都有自己的虚拟机栈,方法内局部变量是栈帧私有的,不涉及共享。
  • 为什么递归不能太深?递归每深入一层就要压一个栈帧,栈的默认深度在HotSpot中受-Xss参数控制,默认通常为512KB到1MB,压栈超过容量就抛StackOverflowError。
  • 为什么方法调用有性能成本?每次调用都要完成栈帧创建、参数传递、跳转和返回,虽然单个成本很小,但高频调用时累积起来是客观的。

理解栈帧这个概念之后,再去看方法相关的字节码指令就容易多了。invokestatic调用静态方法,invokespecial调用私有实例方法、构造器和super方法,invokevirtual调用实例方法(支持多态),invokeinterface调用接口方法。这些指令对应了方法分派的不同方式,后面讲重载和重写时会用到。

1.2 方法重载与重写的底层分派差异

重载(Overload)和重写(Override)是Java方法体系中最容易混淆、也是面试必问的一对概念。两者表面上的区别是“同一个类中方法名相同参数不同”和“子类中方法签名与父类相同”,但底层分派机制完全不同。

重载是静态分派,编译期间就确定了调用哪个方法。javac编译器根据方法参数的声明类型(注意不是运行时类型)来选择最匹配的方法版本。这个过程发生在编译期,生成的字节码中已经写好了调用目标,运行时不再做二次判断。

重写是动态分派,编译期无法确定,要等运行时根据对象的实际类型来决定。HotSpot通过在类的方法区中维护一张虚方法表(vtable)来实现快速的动态分派。子类重写父类方法时,虚方法表中对应的条目会被替换为子类方法的入口地址。调用invokevirtual指令时,JVM会从接收者对象的实际类型对应的虚方法表中查找目标方法。

这里有一个很经典的面试题:有父类Animal和子类Dog,两处代码Animal a = new Dog(); a.bark();,运行时执行的是Dog.bark()还是Animal.bark()?答案是Dog.bark(),因为动态分派看的是对象实际类型,而不是引用变量类型。

我经常建议读者用一个表格把这俩核心差异固化成长期记忆:

对比维度重载(Overload)重写(Override)
发生位置同一个类中父类与子类之间
方法签名方法名相同,参数列表必须不同方法名、参数列表必须完全一致
返回类型可以不同(但仅靠返回类型无法区分重载)必须相同或是父类版本的子类型(协变返回类型)
访问修饰符无限制不能比父类版本更严格(如父类是public,子类不能是private)
异常声明无限制不能抛出比父类版本更宽的受检异常
分派时机编译期(静态分派)运行期(动态分派)
关键词可以用@Override注解辅助校验

在实际业务代码中,重载比较典型的应用场景是工具类的多个同名方法,比如String.valueOf接收不同参数类型;重写典型的应用场景是框架扩展点,比如Spring中重写AbstractApplicationContext的模板方法,或者JDK中AbstractList的子类实现getsize

2. 方法的核心语法与工程实操细节

2.1 值传递还是引用传递?一个反复踩坑的经典问题

关于Java方法的参数传递,最权威的回答是:Java只有值传递,没有引用传递。这句结论我反复说过无数次,但每次带新人时都会发现还是有人理解不到位,或者说理解了但实际写代码时仍犯错。

为什么Java只有值传递?因为“引用传递”的定义是方法接收到的是实参的地址,方法内部修改形参地址能影响实参的指向。而Java在传递引用类型参数时,传递给方法的是“引用变量的副本”——这个副本保存了与实参相同的对象地址。方法内对这个副本本身重新赋值(比如param = new Object())时,只改了副本的指向,实参不受影响;但如果通过副本访问并修改了对象内部状态(比如param.setXxx(...)),由于两个引用指向同一个对象,外部能看到变化。

我用一段示例代码来解释:

public class PassByValueDemo { public static void main(String[] args) { // demo 1: 基本类型参数 int num = 10; changeInt(num); System.out.println("main中num=" + num); // 输出10,值传递,方法内修改不影响外部 // demo 2: 引用类型参数,修改对象内部状态 StringBuilder sb = new StringBuilder("hello"); changeSbContent(sb); System.out.println("main中sb=" + sb); // 输出hello world,因为修改的是对象内部属性 // demo 3: 引用类型参数,重新指向新对象 StringBuilder sb2 = new StringBuilder("hello"); changeSbRef(sb2); System.out.println("main中sb2=" + sb2); // 输出hello,因为只改了形参的指向 // demo 4: 经典交换问题 User u1 = new User("张三"); User u2 = new User("李四"); swap(u1, u2); System.out.println("main中u1=" + u1.name); // 输出张三,交换失败 System.out.println("main中u2=" + u2.name); // 输出李四 } private static void changeInt(int value) { value = 20; } private static void changeSbContent(StringBuilder param) { param.append(" world"); } private static void changeSbRef(StringBuilder param) { param = new StringBuilder("new object"); } private static void swap(User a, User b) { User tmp = a; a = b; b = tmp; } static class User { String name; User(String name) { this.name = name; } } }

注意看demo 3的结果:changeSbRef方法内执行了param = new StringBuilder("new object"),这个操作只是把形参的引用副本指向了一个新对象,对main方法里的sb2毫无影响。这就是“引用变量的副本”和“对象本身”的区别——理解到这一层,才能算是真正弄懂了Java的参数传递。

在实际开发中,我碰到过因为参数传递机制理解不透彻导致的线上问题,比如在某个回调方法里把入参重新指向了从数据库查询出的新对象,结果调用方拿到的还是旧对象,排查了很久才发现是给形参重新赋值了。如果实在需要在方法内改变外部引用的指向,正确做法是返回新对象并重新赋值,或者使用Java提供的java.util.concurrent.atomic.AtomicReference等工具类。

2.2 可变参数、递归的正确姿势与IDEA方法注释模板

可变参数(Varargs)是Java 5引入的语法糖,用类型... 参数名声明,本质上是语法层面的数组封装。编译器会把可变参数转换成一个数组传递,所以方法体内可以直接用数组的方式遍历。需要注意的是,可变参数必须放在参数列表的最后一位,避免歧义;调用时可以不传任何参数,此时传入的是一个长度为0的数组而不是null。

public class VarargsDemo { public static int sum(int... nums) { int total = 0; for (int n : nums) { total += n; } return total; } public static void main(String[] args) { System.out.println(sum()); // 输出0 System.out.println(sum(1, 2, 3)); // 输出6 System.out.println(sum(new int[]{4, 5, 6, 7})); // 也可以直接传数组 } }

这里有一个隐藏的小细节:方法重载时,固定参数的方法优先级高于可变参数的方法。比如同时定义print(String s)print(String... ss),调用print("hello")时Java会选择固定参数版本。这个优先级规则在日常编码中容易忽略,一旦遇到版本升级导致方法误匹配,会产生比较隐蔽的问题。

递归是“方法调用自己”的编程技巧,适合分解子问题的场景,比如遍历树形结构、计算阶乘和斐波那契数列。但Java对递归并不友好,主要原因是:

  • 深递归会快速消耗栈空间,默认栈深度往往撑不过几万层调用。
  • Java目前没有对尾递归做优化(像函数式语言那样把尾递归转成迭代执行),即使写成尾递归形式,栈帧也一样累积。

因此工作几年后,我对递归的态度是“慎用”。能用循环解决的问题,优先用循环;确实适合用递归的场景(比如业务中的树形结构遍历),也要加好深度限制。我曾经在某个项目中遍历一张无限层级的组织架构树,没限制深度,结果数据出现环后直接StackOverflowError,服务重启才恢复。后来代码里统一加了最大深度保护和访问路径去重。

接下来说一个开发体验相关的实操:IDEA中设置方法注释模板。很多团队要求方法上必须写Javadoc,但IDEA默认的/** + 回车只能生成最简单的空注释,加上参数、返回值和作者信息需要自己做模板。我用了很久的一套模板配置如下。

打开IDEA的File → Settings → Editor → Live Templates,新建一个模板组(也可以直接在已有的组里加)。模板的Abbreviation设为*Context勾选Java中的Comment。模板内容填:

** * 功能描述:$description$ * * @author $user$ * @date $date$ $time$ $params$ * @return $returns$ */

Edit variables中做如下配置:

  • description:留空,使用时手动填写。
  • user:填user()函数。
  • date:填date()函数。
  • time:填time("HH:mm:ss")函数。
  • params:填groovyScript一段脚本,自动获取当前方法的所有参数并生成@param xxx行。
groovyScript("def result=''; def params=\"${_1}\".replaceAll('[\\\\[|\\\\]|\\\\s]', '').split(',').toList(); for(i = 0; i < params.size(); i++) { if(params[i] != '') { result += ' * @param ' + params[i] + ((i < params.size() - 1) ? '\\n' : '') } }; return result", methodParameters())

returns:填groovyScript("def result=''; def type=\"${_1}\"; if(type == 'void'){result=''} else {result=' * @return ' + type}; return result", methodReturnType())

设置完成后,在方法上面输入/*然后按Tab,就能自动生成带参数和返回值的Javadoc模板。这个技巧在团队代码规范落地时非常实用,不用再手动敲每个@param

3. 方法的进阶用法:Lambda、方法引用与设计模式

3.1 Lambda表达式的底层逻辑与函数式接口

Java 8引入的Lambda表达式是方法体系里比较大的一个变革。它本质上是对“匿名内部类”的一种简化写法,但底层实现并不完全相同。Lambda表达式最终会通过invokedynamic指令配合LambdaMetafactory在运行时生成函数式接口的实现,而不是在编译期创建一个新的匿名类文件(Java 8之前的匿名内部类会每个类编译出一个独立的.class文件)。

使用Lambda需要满足一个前提:目标类型必须是函数式接口,也就是接口中只包含一个抽象方法。JDK中常用的函数式接口包括:

  • Function<T, R>:接收一个参数T,返回R,对应apply方法。
  • Predicate<T>:接收一个参数T,返回boolean,对应test方法。
  • Consumer<T>:接收一个参数T,不返回结果,对应accept方法。
  • Supplier<T>:无参数,返回T,对应get方法。
  • Runnable:无参数,无返回,对应run方法。

实际开发中,Lambda最大的价值是配合Stream API做集合操作。比如业务中一个常见场景:从订单列表中筛选出金额大于100的订单,再按创建时间排序,最后取前10条的用户手机号集合。用传统for循环写可能要十几行,用Lambda配合Stream可以简化为几行:

List<String> phoneList = orderList.stream() .filter(o -> o.getAmount() > 100) .sorted(Comparator.comparing(Order::getCreateTime)) .limit(10) .map(Order::getUserPhone) .collect(Collectors.toList());

这里用到的Order::getCreateTime就是方法引用(Method Reference)。方法引用是Lambda的一种简洁写法,总共分四种:

  • 静态方法引用:类名::静态方法名,比如Integer::parseInt
  • 实例方法引用:对象实例::实例方法名,比如System.out::println
  • 特定类型的任意对象方法引用:类名::实例方法名,比如String::length
  • 构造器引用:类名::new,比如ArrayList::new

在团队Code Review时,我一般会建议:如果Lambda表达式体只有一行,且只是调用某个已有方法,就改写成方法引用;如果逻辑超过三行,就抽取成一个有明确名字的方法再引用,而不是在Lambda里堆一大段代码。这样既利用了Lambda的简洁,又保持可读性。

3.2 工厂方法模式与模板方法模式在Java中的体现

方法在经典设计模式中扮演着核心角色,其中与“方法”二字关系最紧密的是工厂方法模式(Factory Method Pattern)和模板方法模式(Template Method Pattern)。

工厂方法模式的核心思想是:定义一个创建对象的接口/抽象类,让子类决定实例化哪个具体类。这样做的好处是把对象的创建逻辑和使用逻辑解耦,新增产品类型时不需要修改已有调用方代码,符合开闭原则。典型的结构是:

public abstract class LoggerFactory { // 工厂方法,子类必须实现 protected abstract Logger createLogger(); // 公共业务逻辑依赖工厂方法 public void writeLog(String message) { Logger logger = createLogger(); logger.log(message); } } public class FileLoggerFactory extends LoggerFactory { @Override protected Logger createLogger() { return new FileLogger(); } } public class ConsoleLoggerFactory extends LoggerFactory { @Override protected Logger createLogger() { return new ConsoleLogger(); } }

模板方法模式的思路则是:父类定义一套算法的骨架(模板方法),把某些步骤延迟到子类中实现。子类不能改变整体算法流程,但可以改变其中某些步骤的具体实现。JDK中最典型的例子是AbstractList,它的addAll方法在父类中定义了流程,但内部依赖的add方法由子类实现;还有Servlet中的HttpServletservice方法定义了请求处理流程,doGetdoPost留给子类重写。

这两个模式在面试中经常被拿来对比:

对比维度工厂方法模式模板方法模式
目的对象创建的封装与解耦算法流程的复用与步骤定制
子类行为决定创建哪种对象决定某一步怎么做
核心方法工厂方法(返回产品对象)模板方法(定义流程骨架)
常见场景日志框架、连接池创建JdbcTemplate、Spring的AbstractApplicationContext

在实际源码阅读中,你会发现Spring Framework把这俩模式用得淋漓尽致。看BeanFactoryApplicationContext的继承体系时,不妨带着这两种模式的视角去看,会顺畅很多。

3.3 JDK动态代理:方法拦截与增强的原理

动态代理是Java方法体系里一个“面试常问、工作中少见但必须懂”的主题。JDK动态代理基于接口实现,主要涉及两个类:java.lang.reflect.Proxyjava.lang.reflect.InvocationHandler

使用方式很固定:定义一个接口,提供实现类,然后通过Proxy.newProxyInstance(ClassLoader, Class<?>[], InvocationHandler)创建代理对象。被代理的方法调用会统一进入InvocationHandlerinvoke(Object proxy, Method method, Object[] args)方法中,从而实现对目标方法的拦截和增强。

public interface UserService { void saveUser(String name); } public class UserServiceImpl implements UserService { @Override public void saveUser(String name) { System.out.println("保存用户: " + name); } } public class UserServiceProxy { public static UserService createProxy(UserService target) { return (UserService) Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), (proxy, method, args) -> { System.out.println("前置增强:方法调用前打印日志"); Object result = method.invoke(target, args); System.out.println("后置增强:方法调用完毕后打印耗时"); return result; } ); } public static void main(String[] args) { UserService target = new UserServiceImpl(); UserService proxy = createProxy(target); proxy.saveUser("张三"); } }

JDK动态代理的原理是:运行时动态生成一个代理类的字节码,这个代理类实现了传入的所有接口,并且持有InvocationHandler引用。调用代理对象任意接口方法时,代理类内部会调用InvocationHandler.invoke,从而实现对目标方法的转发和增强。

关键点在于——JDK动态代理只能代理接口,不能代理类。如果目标对象没有实现任何接口,就只能考虑CGLIB(基于继承生成子类,通过覆写方法实现拦截)。这正好解释了为什么很多框架强调“面向接口编程”:接口不仅是架构上的解耦,也是JDK动态代理能生效的前提。Spring的AOP默认策略就是:目标类实现了接口就用JDK动态代理,否则用CGLIB。

4. 常见问题与排查技巧实录

4.1 重载匹配时踩过的坑:null参数的多态陷阱

重载方法的匹配规则虽然由编译器决定,但有些边界情况非常反直觉。最经典的一个是:方法重载接受不同类型的参数,调用时传入null,编译器会怎么选?

public class OverloadNullDemo { public static void print(String s) { System.out.println("String版本"); } public static void print(Object o) { System.out.println("Object版本"); } public static void main(String[] args) { // 猜猜调用的是哪个? print(null); } }

答案是String版本。原因在于Java编译器的重载匹配有一个“最具体”原则:两个方法都可以接收null,但StringObject的子类型,String版本更具体,所以编译器优先选择更具体的版本。

如果再加一个print(Integer i),那print(null);就会编译报错,因为编译器无法判断StringInteger哪个更“接近”null,出现了歧义。这类问题虽然看起来偏“八股”,但实际写工具类、通用组件的时候真的会撞上,我在抽象一个通用的空值处理工具时就遇到过调用方传null导致编译不过的情况,被迫重新设计了方法签名。

我的建议是:重载方法时尽量避免出现“参数类型之间存在继承关系,且由于业务需要都需要接收null”的情况。如果无法避免,就在入口处做一次类型强转来消除歧义,或者干脆换不同方法名,牺牲一点命名优雅度换来调用方的确定性和安全性。

4.2 递归引发的栈溢出:排查思路与预防手段

StackOverflowError是我在开发中见过相当多次的异常,绝大多数出现在递归场景。一种典型的误用是递归没有写终止条件,或者终止条件在特定数据下永远不会满足;另一种是递归深度本身太大,即使逻辑正确也会压爆栈。

排查栈溢出问题,我一般按照以下步骤来:

  • 抓取异常栈,定位到具体是哪个方法不断重复入栈。通常异常栈里会连续出现同一个方法名或同一组方法名。
  • 检查递归终止条件是否在所有可能的数据输入下都能被满足,尤其要注意业务数据是否存在环。
  • 评估递归深度和JVM栈容量。如果确实需要很深递归,可以调大-Xss参数(比如-Xss2m),但这只是权宜之计,不能从根本上解决问题。
  • 权衡是否改成循环或迭代写法。
现象常见原因处理方式
栈溢出异常且异常栈中同一方法反复出现递归终止条件缺失或数据成环检查终止条件,增加深度上限,对访问路径去重
数据量正常但递归深度仍超万层算法本身递归深度过大改用循环、迭代或显式栈模拟
多线程环境下偶发栈溢出某线程栈容量设置过小适当调大-Xss,或评估是否真需要深递归
递归效率极低,方法反复计算相同子问题没有做记忆化缓存增加缓存(如用Map存储已计算结果)

说到记忆化缓存,就不得不提一个常见的性能问题:直接用递归实现斐波那契数列,时间复杂度是指数级的,因为大量子问题被重复计算。加上一个Map<Integer, Long>作为缓存后,性能会有质的提升。这个优化思路在树形结构求和、组织架构递归查询中同样实用。

4.3 动态代理常见的坑:强转失败与多层代理

用JDK动态代理时,我遇到过不少同学在“强转”上报ClassCastException。原因几乎都指向同一个:Proxy.newProxyInstance生成的代理对象只能转换成接口类型,不能强转成实现类类型。因为代理类与实现类是平行的,没有继承关系,它们只共同实现了同一个接口。

// 错误做法:强行转成实现类 UserServiceImpl implProxy = (UserServiceImpl) Proxy.newProxyInstance( UserServiceImpl.class.getClassLoader(), UserServiceImpl.class.getInterfaces(), handler );

这段代码运行时必然抛ClassCastException。正确做法是把代理对象声明为接口类型:

UserService proxy = (UserService) Proxy.newProxyInstance(...);

另一个容易踩的坑是“多层代理导致方法重复增强”。比如给同一个目标对象先后创建了两个代理对象,第一个代理对象又作为第二个代理对象的目标,会导致调用链路重复执行切面逻辑。排查这类问题时,可以在InvocationHandler.invoke方法里打印方法调用栈,看看方法是被哪个代理类转发进来的。

还有一点不得不提:即使抛开代理,直接用反射Method.invoke调用方法时,也有一个隐藏的性能和访问性问题。反射调用会做访问检查和参数包装,性能比直接调用慢很多。JDK在高频反射调用场景下会尝试生成MethodAccessor来优化,但如果追求极致性能,业界一般用MethodHandle或者直接用编译期确定调用的方式替代。

4.4 方法设计层面的坏味道与重构建议

从代码评审的角度来看,方法相关的问题远不止语法层面的坑,更多是设计层面的坏味道。我在团队Code Review时重点关注以下几点:

第一,方法过长。一个方法超过二三十行时,可读性会急剧下降,也意味着它可能承担了多个职责。重构成多个语义清晰的小方法后,主流程会像读文章大纲一样清晰。

第二,参数过多。方法参数超过四五个时,调用方很难记住每个参数的含义和顺序,也容易传错。此时建议引入参数对象,比如把多个业务参数封装成一个OrderQuery对象。

第三,方法间的重复逻辑。如果多处出现类似的循环加条件判断,应该抽成公共方法或工具方法。这个不光是DRY原则的问题,更是后续维护成本的问题——修改业务规则时,你肯定不希望到十几个地方同步改。

第四,返回值设计不清晰。一个方法要么返回结果,要么抛出异常,最怕的是“返回null表示失败,但调用方忘了判空”。我在这类问题上吃过亏后,现在更倾向于用Optional明确表达“可能为空”的语义,或者让方法直接抛出明确的业务异常。

最后想说的话

Java方法的核心语法其实不难,难的是把“方法调用”放进JVM运行机制这个大背景里去理解。从栈帧的创建销毁,到重载重写的分派规则,再到Lambda、动态代理这些进阶能力,看似零散的知识点,本质上都指向同一件事——方法在Java语言和JVM中扮演着“行为单元”和“运行载体”的双重角色。我在实际开发中反复体会到,把这些底层机制吃透之后,阅读Spring、MyBatis这些框架源码时明显顺畅了很多,排查线上问题也不会再“瞎猜”。所以如果你正处在Java入门或初中级阶段,建议在“方法”这一章多花点时间,不要只停留于会写,还要弄明白调用背后发生了什么。

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

ITIL4运维管理变革:从流程到价值创造

1. ITIL4带来的运维管理变革ITIL4的发布标志着IT服务管理领域的一次重大升级。作为从业15年的IT运维老兵&#xff0c;我亲眼见证了从ITIL v3到ITIL4的演进过程。这套框架不再只是单纯的服务管理方法论&#xff0c;而是正在重塑整个运维管理的"游戏规则"。最直观的变化…

作者头像 李华
网站建设 2026/9/11 7:17:03

如何为 Midscene.js 搭建容器化服务:Docker 部署完整指南

如何为 Midscene.js 搭建容器化服务&#xff1a;Docker 部署完整指南 &#x1f525;【免费下载链接】midscene GUI Agent for E2E Testing 项目地址: https://gitcode.com/GitHub_Trending/mid/midscene Midscene.js 是基于视觉语言模型的 GUI 自动化工具&#xff0c;可…

作者头像 李华
网站建设 2026/9/11 7:15:35

ML-KWS-for-MCU源码审计:MCU关键词识别工程的得与失

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华