你好欢迎来到我的博客我是【菜鸟不学编程】我是一个正在奋斗中的职场码农步入职场多年正在从“小码农”慢慢成长为有深度、有思考的技术人。在这条不断进阶的路上我决定记录下自己的学习与成长过程也希望通过博客结识更多志同道合的朋友。️ 主要方向包括 Java 基础、Spring 全家桶、数据库优化、项目实战等也会分享一些踩坑经历与面试复盘希望能为还在迷茫中的你提供一些参考。 我相信写作是一种思考的过程分享是一种进步的方式。如果你和我一样热爱技术、热爱成长欢迎关注我一起交流进步全文目录I. 字节码基础JVM 指令集和 Class 文件结构1JVM 指令集你写的“语义”最终会变成“动作”2Class 文件结构最关键的是这些部件II. ASM 库Visitor 模式和字节码生成1一个“能看懂”的 ASM 插桩给方法加计时入口 退出III. Javassist简单 API 的类修改1Javassist 给方法插一句打印IV. 与代理结合动态生成代理类1JDK 动态代理接口级——先热热身2字节码 代理的典型工程形态V. 性能监控插入计时代码别只插主路径异常路径也要算1正确姿势try/finally 语义ASM/Javassist 都要保证2再加一个“防二次增强”的小策略工程很常用VI. 项目自定义注解处理器的字节码注入一个更像工程的组合拳目标1定义注解Timed2注解处理器收集所有 Timed 方法生成一个索引文件3构建阶段字节码注入读 timed.index用 ASM 对目标方法织入计时4给个业务类试试你只写注解不写计时代码迁移与实战小建议不装酷但真有用结尾字节码不是黑魔法它是“把能力塞进系统”的工程手段 写在最后I. 字节码基础JVM 指令集和 Class 文件结构字节码这件事别上来就背规范我建议你先抓两个“能落地”的核心Class 文件是结构化二进制魔数、版本、常量池、字段表、方法表、属性表……它不是一坨随机 bytes。方法体最终是指令序列加载、存储、调用、跳转、返回外加异常表、栈映射帧StackMapFrames等“保证你不把 JVM 玩崩”的护栏。1JVM 指令集你写的“语义”最终会变成“动作”举个最直观的例子intadd(inta,intb){returnab;}它在字节码层面大概就是把局部变量表里的 a、b 压栈执行iaddireturn你会发现字节码不关心“你怎么想”只关心“你怎么做”。这也是为什么做字节码插桩时你要小心栈高度、局部变量索引、异常路径——业务逻辑的“直觉”在字节码里经常不适用。2Class 文件结构最关键的是这些部件Constant Pool常量池字符串、类名、方法符号引用、字段引用……很多改动最终都绕不过它Methods Code Attribute方法的字节码、最大栈深、局部变量数量、异常表Attributes例如 LineNumberTable调试行号、LocalVariableTable局部变量信息、StackMapTable校验用小吐槽你第一次改字节码不小心把 StackMapTable 搞坏报错信息可能像天书一样……别怀疑自己智商那玩意儿确实很不友好。II. ASM 库Visitor 模式和字节码生成ASM 的气质就像“手术刀”锋利、精准、但很考手稳。它的主线思路是 Visitor访问者ClassReader读 classClassVisitor/MethodVisitor逐步访问结构ClassWriter写回修改后的 class1一个“能看懂”的 ASM 插桩给方法加计时入口 退出目标给一个方法包上计时逻辑longstartSystem.nanoTime();try{原方法体}finally{System.out.println(cost(System.nanoTime()-start));}用 ASM 的AdviceAdapter更适合这种“在方法前后插入代码”的场景importorg.objectweb.asm.*;importorg.objectweb.asm.commons.AdviceAdapter;publicclassTimingClassVisitorextendsClassVisitor{privateStringclassName;publicTimingClassVisitor(ClassVisitorcv){super(Opcodes.ASM9,cv);}Overridepublicvoidvisit(intversion,intaccess,Stringname,Stringsignature,StringsuperName,String[]interfaces){this.classNamename;super.visit(version,access,name,signature,superName,interfaces);}OverridepublicMethodVisitorvisitMethod(intaccess,Stringname,Stringdesc,Stringsignature,String[]exceptions){MethodVisitormvsuper.visitMethod(access,name,desc,signature,exceptions);// 过滤构造器/抽象方法/本地方法别乱插if(mvnull)returnnull;if((access(Opcodes.ACC_ABSTRACT|Opcodes.ACC_NATIVE))!0)returnmv;if(name.equals(init)||name.equals(clinit))returnmv;returnnewAdviceAdapter(Opcodes.ASM9,mv,access,name,desc){privateintstartTimeVarIndex;OverrideprotectedvoidonMethodEnter(){// long start System.nanoTime();invokeStatic(Type.getType(System.class),neworg.objectweb.asm.commons.Method(nanoTime,()J));startTimeVarIndexnewLocal(Type.LONG_TYPE);storeLocal(startTimeVarIndex,Type.LONG_TYPE);}OverrideprotectedvoidonMethodExit(intopcode){// System.out.println(cost (System.nanoTime() - start));getStatic(Type.getType(System.class),out,Type.getType(java.io.PrintStream.class));newInstance(Type.getType(StringBuilder.class));dup();invokeConstructor(Type.getType(StringBuilder.class),neworg.objectweb.asm.commons.Method(init,()V));push(className.replace(/,.)#name cost(ns));invokeVirtual(Type.getType(StringBuilder.class),neworg.objectweb.asm.commons.Method(append,(Ljava/lang/String;)Ljava/lang/StringBuilder;));invokeStatic(Type.getType(System.class),neworg.objectweb.asm.commons.Method(nanoTime,()J));loadLocal(startTimeVarIndex,Type.LONG_TYPE);math(SUB,Type.LONG_TYPE);invokeVirtual(Type.getType(StringBuilder.class),neworg.objectweb.asm.commons.Method(append,(J)Ljava/lang/StringBuilder;));invokeVirtual(Type.getType(StringBuilder.class),neworg.objectweb.asm.commons.Method(toString,()Ljava/lang/String;));invokeVirtual(Type.getType(java.io.PrintStream.class),neworg.objectweb.asm.commons.Method(println,(Ljava/lang/String;)V));}};}}再配一个入口把 class bytes 读进来写出去概念版importorg.objectweb.asm.*;publicclassAsmWeaver{publicstaticbyte[]weave(byte[]original){ClassReadercrnewClassReader(original);ClassWritercwnewClassWriter(cr,ClassWriter.COMPUTE_FRAMES|ClassWriter.COMPUTE_MAXS);ClassVisitorcvnewTimingClassVisitor(cw);cr.accept(cv,ClassReader.EXPAND_FRAMES);returncw.toByteArray();}}COMPUTE_FRAMES / COMPUTE_MAXS这俩选项像“自动驾驶”能救你很多栈帧计算的命但也会让构建变慢一点。工程里一般是开发/验证阶段开着少踩坑稳定后看是否需要优化编译耗时III. Javassist简单 API 的类修改如果 ASM 像手术刀那 Javassist 就像“带说明书的工具箱”你可以用更接近 Java 语法的方式改方法体。优点是上手快缺点是复杂场景可控性不如 ASM。1Javassist 给方法插一句打印importjavassist.*;publicclassJavassistPatch{publicstaticbyte[]insertLog(byte[]clazzBytes,StringclassName)throwsException{ClassPoolpoolClassPool.getDefault();CtClassctpool.makeClass(newjava.io.ByteArrayInputStream(clazzBytes));CtMethodmct.getDeclaredMethod(doWork);m.insertBefore({ System.out.println(\enter doWork\); });m.insertAfter({ System.out.println(\exit doWork\); },true);byte[]outct.toBytecode();ct.detach();returnout;}}insertAfter(..., true)的true很关键它表示就算方法抛异常也会执行 after类似 finally。这类小细节才是“能不能上生产”的分界线——别小看它。IV. 与代理结合动态生成代理类很多人听到“代理”第一反应是 JDK 动态代理/CGLIB。它们确实常用但字节码操作能把玩法拓展到更野运行时生成一个新类代理类或者直接改掉目标类字节码更像 AOP 的底层1JDK 动态代理接口级——先热热身importjava.lang.reflect.*;publicclassJdkProxyDemo{publicinterfaceService{Stringhi(Stringname);}publicstaticclassServiceImplimplementsService{publicStringhi(Stringname){returnhi name;}}publicstaticvoidmain(String[]args){ServicetargetnewServiceImpl();Serviceproxy(Service)Proxy.newProxyInstance(Service.class.getClassLoader(),newClass[]{Service.class},(Objectp,Methodmethod,Object[]a)-{longsSystem.nanoTime();try{returnmethod.invoke(target,a);}finally{System.out.println(method.getName() cost(System.nanoTime()-s));}});System.out.println(proxy.hi(Ophelia));}}它的局限也很明显必须有接口。如果你要代理普通类、要改构造器、要插到任意方法——字节码工具才是主角。2字节码 代理的典型工程形态Java Agent启动时或运行时Instrumentation修改 classBuild-time weaving构建阶段改 class最可控、最稳定生产最常见运行时生成新类更灵活但排障成本更高这章后面的项目我就用“构建期注入”的方式稳一点也更像团队能接受的方案。V. 性能监控插入计时代码别只插主路径异常路径也要算计时插桩最大的坑不是“插不进去”而是“插进去但数据不可信”。两个常见翻车点异常返回没统计只在正常 return 前打印异常直接跳过重复统计/嵌套统计一个方法被多次增强日志翻倍指标崩坏1正确姿势try/finally 语义ASM/Javassist 都要保证你要的是不管 return 还是 throw都走统计逻辑。前面 ASM 的AdviceAdapter.onMethodExit已经覆盖了各种退出 opcodeJavassist 用insertAfter(..., true)也是同理。2再加一个“防二次增强”的小策略工程很常用做字节码注入时可以给类/方法加一个标记属性或注解或者在常量池写入一个特征字符串下次再扫描到就跳过。否则你在多模块构建、增量编译、重复打包时很容易“越织越厚”最后日志像瀑布一样冲出来……VI. 项目自定义注解处理器的字节码注入一个更像工程的组合拳这里我要说句“比较现实”的话纯注解处理器Annotation Processor本职是生成源码/资源它并不擅长直接“改已经编译好的 .class”。但我们完全可以做一个工程上更稳的方案方案注解处理器负责“收集信息”构建阶段任务负责“字节码注入”。这样既符合 Java 编译链的习惯也不容易跟编译器打架。目标我们做一个注解Timed标在方法上就自动注入计时逻辑。1定义注解Timedimportjava.lang.annotation.*;Target(ElementType.METHOD)Retention(RetentionPolicy.CLASS)// 只要进到 class 文件即可publicinterfaceTimed{Stringvalue()default;}2注解处理器收集所有 Timed 方法生成一个索引文件这个处理器不改 class它只生成一个资源文件比如META-INF/timed.index文件内容类似com.myapp.DemoService#doWork()V com.myapp.UserService#login(Ljava/lang/String;)Z处理器核心思路示例importjavax.annotation.processing.*;importjavax.lang.model.element.*;importjavax.lang.model.*;importjavax.tools.FileObject;importjavax.tools.StandardLocation;importjava.io.*;importjava.util.*;SupportedAnnotationTypes(com.myapp.Timed)SupportedSourceVersion(SourceVersion.RELEASE_17)publicclassTimedProcessorextendsAbstractProcessor{Overridepublicbooleanprocess(Set?extendsTypeElementannotations,RoundEnvironmentroundEnv){ListStringlinesnewArrayList();for(Elemente:roundEnv.getElementsAnnotatedWith(Timed.class)){if(e.getKind()!ElementKind.METHOD)continue;ExecutableElementm(ExecutableElement)e;TypeElementowner(TypeElement)m.getEnclosingElement();StringownerNameprocessingEnv.getElementUtils().getBinaryName(owner).toString();StringmethodNamem.getSimpleName().toString();StringdescmethodDescriptor(m);lines.add(ownerName#methodNamedesc);}if(!lines.isEmpty()){try{FileObjectfoprocessingEnv.getFiler().createResource(StandardLocation.CLASS_OUTPUT,,META-INF/timed.index);try(Writerwfo.openWriter()){for(Strings:lines)w.write(s\n);}}catch(IOExceptionex){thrownewRuntimeException(ex);}}returntrue;}// 简化版 descriptor 生成演示用privatestaticStringmethodDescriptor(ExecutableElementm){StringBuildersbnewStringBuilder(();for(VariableElementp:m.getParameters()){sb.append(jvmType(p.asType()));}sb.append()).append(jvmType(m.getReturnType()));returnsb.toString();}// 仅覆盖常见类型演示用工程里建议写完整映射privatestaticStringjvmType(TypeMirrort){returnswitch(t.getKind()){caseINT-I;caseLONG-J;caseBOOLEAN-Z;caseVOID-V;default-Ljava/lang/Object;;};}}这里我故意把 descriptor 映射写得“朴素”让你看懂流程。真上工程建议用成熟工具生成 descriptor或者自己写全类型映射否则数组、泛型擦除后类型、内部类参数等会让你抓狂。3构建阶段字节码注入读 timed.index用 ASM 对目标方法织入计时你可以在 Gradle/Maven 的某个 task 阶段classes 之后、jar 之前扫描build/classes/java/main或 target/classes读取META-INF/timed.index对命中的 class/method 执行AsmWeaver.weave伪代码表达工程流程// 1) 从 classes 输出目录读取 META-INF/timed.index// 2) 将每条记录解析为 (className, methodName, desc)// 3) 对对应 class 文件执行 ASM只增强匹配的方法而 ASM 侧对方法做匹配name.equals(methodName)desc.equals(methodDesc)命中才插桩避免“全项目乱织”。4给个业务类试试你只写注解不写计时代码publicclassDemoService{Timed(work)publicvoiddoWork(){try{Thread.sleep(50);}catch(InterruptedExceptionignored){}}}构建后运行你会看到输出类似com.myapp.DemoService#doWork cost(ns)...这就是“像工程”的体验业务代码干净能力在构建期注入。而且你还保留了可控性只对标注的方法生效不会全局乱搞。迁移与实战小建议不装酷但真有用字节码操作这玩意儿最怕“为了炫技把系统搞成玄学”。我建议你至少守住这几条底线优先构建期注入稳定、可回溯、可复现排障成本低增强要可开关别把监控逻辑写死最好能通过构建参数/配置开关控制保留行号/调试信息否则线上栈追踪会变得很难读尤其插桩后防重复增强不然你会得到“双倍日志、三倍开销、四倍痛苦”‍性能别想当然插桩本身会带开销建议采样/异步上报而不是每次都 printlnprintln 只是演示结尾字节码不是黑魔法它是“把能力塞进系统”的工程手段如果你把字节码当成“炫技”它会反过来教育你但如果你把它当成“工程能力”它会在很多关键时刻帮你省下大量侵入式改造的成本。最后我想抛你一句反问别嫌我嘴欠你现在做监控/埋点/权限校验是不是还在“改业务代码 到处复制粘贴”如果是那字节码注入真的值得你认真学一遍。 写在最后如果你觉得这篇文章对你有帮助或者有任何想法、建议欢迎在评论区留言交流你的每一个点赞 、收藏 ⭐、关注 ❤️都是我持续更新的最大动力我是一个在代码世界里不断摸索的小码农愿我们都能在成长的路上越走越远越学越强感谢你的阅读我们下篇文章再见✍️ 作者某个被流“治愈”过的 Java 老兵 日期2026-01-07 本文原创转载请注明出处。