ASM字节码编程 | 如果你只写CRUD,那这种技术栈你永远碰不到!!!
小傅哥 | https://bugstack.cn
沉淀、分享、成长,专注于原创专题案例,以最易学习编程的方式分享知识,让自己和他人都能有所收获。目前已完成的专题有;Netty4.x实战专题案例、用Java实现JVM、基于JavaAgent的全链路监控、手写RPC框架、架构设计专题案例、源码分析、算法学习等。
一、前言
写这篇文章的时候我在想可能大部分程序员包括你我,常常都在忙于业务开发或奔波在日常维护与修复BUG的路上,当不能从中吸取技术营养与改变现状后,就像一台恒定运行的机器,逃不出限定宇宙速度的一个圈里。可能你也会有自己的难处,平时加班太晚没有时间学习、周末家里琐事太多没有精力投入,放假计划太满没有空闲安排。总之,学习就会被搁置。而当一年年的过去后,当自己的年龄与能力不成匹配后又会后悔没有给多投入一些时间学习成长。
尤其是一线编码的技术人,除了我们所能看到的在技术框架里(SSM)开发的业务代码,你是否有遇到过学习瓶颈,而这种瓶颈又是你自己不知道自己不会什么,就像下面这些技术列表里,你有了解多少;
1. javaagent
2. asm
3. jvmti
4. javaassit
5. netty
6. 算法,搜索引擎
7. cglib
8. 混沌工程
9. 中间件开发
10. 高级测试;压力测试、链路测试、流量回放、流量染色
11. 故障系列;突袭、重现、演练
12. 分布式的数据一致性
13. 文件操作;es、hive
14. 注册中心;zookeeper、Eureka
15. 互联网工程开发技术栈;spring、mybaits、网关、rpc(thrift, grpc, dubbo)、mq、缓存redis、分库分表、定时任务、分布式事物、限流、熔断、降级
16. 数据库binlog解析
17. 架构设计;DDD领域驱动设计、微服务、服务治理
18. 容器;k8s, docker
19. 分布式存储;ceph
20. 服务istio
21. 压测 jmter
22. Jenkins-部署java代码项目 + ansible
23. 全链路监控,分布式追踪
24. 语音识别、语音合成
26. lvs nginx haproxy iptables
27. hadoop mapreduce hive sqoop hbase flink kylin druid
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
那么!在本公众号(bugstack虫洞栈)中,会专门介绍一些高级技术的应用,可能在平时开发中看不到,但是却一直出现在你的框架中,以某个支撑服务而存在。好,现在开始就搞一下其中的一个技术点 ASM
,看看它的真面目。那么学习之前先看下他有什么用途;
- 类的代理,如cglib
- 混沌工程
- 反向工程
- 结合
javaagent
做到非入侵式监控,方法耗时、日志、机器性能等等 - 破解
ASM 是一个 Java 字节码操控框架。它能被用来动态生成类或者增强既有类的功能。ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为。Java class 被存储在严格格式定义的 .class 文件里,这些类文件拥有足够的元数据来解析类中的所有元素:类名称、方法、属性以及 Java 字节码(指令)。ASM 从类文件中读入信息后,能够改变类行为,分析类信息,甚至能够根据用户要求生成新类。
为了更方便的学习ASM
,我将《ASM4使用手册》以及一些技术点整理成在线文档,可以随时方便查阅(http://asm.itstack.org);
另外关于本文中出现的代码例子,可以通过在公众号(bugstack虫洞栈)内回复,源码下载获取。
二、环境配置
- jdk 1.8
- idea 2019.3.1
- asm-commons 6.2.1
三、工程信息
- itstack-demo-asm-01:字节码编程,HelloWorld
- itstack-demo-asm-02:字节码编程,两数之和
- itstack-demo-asm-03:字节码增强,输出入参
- itstack-demo-asm-04:字节码增强,调用外部方法
以上源码可以通过关注公众号:bugstack虫洞栈
,回复 下载源码
获取
四、HelloWorld还可以这样写
你所熟悉的HelloWorld是不这样;
public class HelloWorld { public static void main(String[] var0) { System.out.println("Hello World"); }
}
- 1
- 2
- 3
- 4
- 5
那你有尝试反解析下他的类查看下汇编指令吗,javap -c HelloWorld
public class org.itstack.demo.test.HelloWorld {
public org.itstack.demo.test.HelloWorld(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public static void main(java.lang.String[]); Code: 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #3 // String Hello World 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
指令 | 描述 |
---|---|
getstatic | 获取静态字段的值 |
ldc | 常量池中的常量值入栈 |
invokevirtual | 运行时方法绑定调用方法 |
return | void函数返回 |
如果你还感兴趣其他指令,可以参考这个字节码指令表:Go!
好! 以上呢,是我很熟悉的一段代码了,那么现在我们把这段代码用ASM方式写出来;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
private static byte[] generate() { ClassWriter classWriter = new ClassWriter(0); // 定义对象头;版本号、修饰符、全类名、签名、父类、实现的接口 classWriter.visit(Opcodes.V1_7, Opcodes.ACC_PUBLIC, "org/itstack/demo/asm/AsmHelloWorld", null, "java/lang/Object", null); // 添加方法;修饰符、方法名、描述符、签名、异常 MethodVisitor methodVisitor = classWriter.visitMethod(Opcodes.ACC_PUBLIC + Opcodes.ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null); // 执行指令;获取静态属性 methodVisitor.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); // 加载常量 load constant methodVisitor.visitLdcInsn("Hello World"); // 调用方法 methodVisitor.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false); // 返回 methodVisitor.visitInsn(Opcodes.RETURN); // 设置操作数栈的深度和局部变量的大小 methodVisitor.visitMaxs(2, 1); // 方法结束 methodVisitor.visitEnd(); // 类完成 classWriter.visitEnd(); // 生成字节数组 return classWriter.toByteArray();
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
以上的代码,“小朋友,你是否有很多问好???^1024”,其实以上的代码都是来自于 ASM
框架的代码,这里面所有的操作与我们使用使用 javap -c XXX
所反解析出的字节码是一样的,只不过是反过来使用指令来编写代码。
-
定义一个类的生成
ClassWriter
-
设定版本、修饰符、全类名、签名、父类、实现的接口,其实也就是那句;
public class HelloWorld
-
接下来开始创建方法,方法同样需要设定;修饰符、方法名、描述符等。这里面有几个固定标识;
-
类型描述符
| Java 类型 | 类型描述符 |
|:—|:—|
| boolean | Z |
| char | C |
| byte | B |
| short | S |
| int | I |
| float | F |
| long | J |
| double | D |
| Object | Ljava/lang/Object; |
| int[] | [I |
| Object[][] | [[Ljava/lang/Object; | -
方法描述符
| 源文件中的方法声明 | 方法描述符 |
|:—|:—|
| void m(int i, float f) | (IF)V |
| int m(Object o) | (Ljava/lang/Object;)I |
| int[] m(int i, String s) | (ILjava/lang/String;)[I |
| Object m(int[] i) | ([I)Ljava/lang/Object; |
([Ljava/lang/String;)V
== void main(String[] args) -
-
执行指令;获取静态属性。主要是获得
System.out
-
加载常量 load constant,输出我们的HelloWorld
methodVisitor.visitLdcInsn("Hello World");
-
最后是调用输出方法并设置空返回,同时在结尾要设置操作数栈的深度和局部变量的大小
这样输出一个 HelloWorld
是不还是蛮有意思的,虽然你可能觉得这编码起来实在太难了吧,也非常难理解。首先如果你看过我的专栏,用《Java写一个Jvm虚拟机》,那么你可能会感受到这里面的知识点还是不那么陌生的。另外这里的编写,ASM还提供了插件,可以方便的让你开发字节码。接下来就介绍一下使用方式。
五、有插件的帮助字节码开发也不是很难
对于新人来说如果用字节码增强开发一些东西确实挺难,尤其是一些复杂的代码块使用字节码指令操作还是很有难度的。那么,其实也是有简单办法就是使用 ASM
插件。这个插件可以很轻松的让你看到一段代码的指令码以及如何用ASM去开发。
-
安装插件(ASM Bytecode Outline)
-
测试使用
是不是看到有插件的帮助下,心里有所激动了,至少写这样的东西有了抓手。这样你就可以很方便的去操作一些增强字节码的功能了。
六、用字节码写出一个两数之和计算
好!有了上面的插件,也有了一些基础知识的了解。那么我们开发一个计算两数之和的方法,之后运行计算结果。
这是我们的目标
public class SumOfTwoNumbers { public int sum(int i, int m) { return i + m; }
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
使用字节码编程方式实现
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
private static byte[] generate() { ClassWriter classWriter = new ClassWriter(0); { MethodVisitor methodVisitor = classWriter.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null); methodVisitor.visitVarInsn(Opcodes.ALOAD, 0); methodVisitor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false); methodVisitor.visitInsn(Opcodes.RETURN); methodVisitor.visitMaxs(1, 1); methodVisitor.visitEnd(); } { // 定义对象头;版本号、修饰符、全类名、签名、父类、实现的接口 classWriter.visit(Opcodes.V1_7, Opcodes.ACC_PUBLIC, "org/itstack/demo/asm/AsmSumOfTwoNumbers", null, "java/lang/Object", null); // 添加方法;修饰符、方法名、描述符、签名、异常 MethodVisitor methodVisitor = classWriter.visitMethod(Opcodes.ACC_PUBLIC, "sum", "(II)I", null, null); methodVisitor.visitVarInsn(Opcodes.ILOAD, 1); methodVisitor.visitVarInsn(Opcodes.ILOAD, 2); methodVisitor.visitInsn(Opcodes.IADD); // 返回 methodVisitor.visitInsn(Opcodes.IRETURN); // 设置操作数栈的深度和局部变量的大小 methodVisitor.visitMaxs(2, 3); methodVisitor.visitEnd(); } // 类完成 classWriter.visitEnd(); // 生成字节数组 return classWriter.toByteArray();
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
-
上面有两个括号
{}
,第一个是用于生成一个空的构造函数public AsmSumOfTwoNumbers() { }
- 1
- 2
-
接下来的指令就比较简单了,首先使用
ILOAD
进行数值的两次压栈也就是弄到操作数栈里去操作,接下来开始执行IADD
,将两数相加。 -
最后返回结果
IRETURN
,注意是返回的I
类型。到此这段方法快就实现完成了。反编译后如下;// // Source code recreated from a .class file by IntelliJ IDEA // (powered by Fernflower decompiler) // package org.itstack.demo.asm; public class AsmSumOfTwoNumbers { public AsmSumOfTwoNumbers() { } public int doSum(int var1, int var2) { return var1 + var2; } }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
执行代码块
public static void main(String[] args) throws Exception { // 生成二进制字节码 byte[] bytes = generate(); // 输出字节码 outputClazz(bytes); // 加载AsmSumOfTwoNumbers GenerateSumOfTwoNumbers generateSumOfTwoNumbers = new GenerateSumOfTwoNumbers(); Class<?> clazz = generateSumOfTwoNumbers.defineClass("org.itstack.demo.asm.AsmSumOfTwoNumbers", bytes, 0, bytes.length); // 反射获取 main 方法 Method method = clazz.getMethod("sum", int.class, int.class); Object obj = method.invoke(clazz.newInstance(), 6, 2); System.out.println(obj);
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 这段执行操作和我们在使用 java 的反射操作一样,也是比较容易的。此时我们是调用了新的字节码类,同时还将字节码输出方便我们查看生成的
class
类。
七、在原有方法上字节码增强监控耗时
到这我们基本了解到通过字节码编程,可以动态的生成一个类。但是在实际使用的过程中,我们可能有的时候是需要修改一个原有的方法,在开始和结尾添加一些代码,来监控这个方法的耗时。这也是非侵入式监控的最基本模型。
定义一个方法
public class MyMethod { public String queryUserInfo(String uid) { System.out.println("xxxx"); System.out.println("xxxx"); System.out.println("xxxx"); System.out.println("xxxx"); return uid; }
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
像这个方法插入监控
public class TestMonitor extends ClassLoader { public static void main(String[] args) throws IOException, NoSuchMethodException, IllegalAccessException, InstantiationException, InvocationTargetException { ClassReader cr = new ClassReader(MyMethod.class.getName()); ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS); { MethodVisitor methodVisitor = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null); methodVisitor.visitVarInsn(Opcodes.ALOAD, 0); methodVisitor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false); methodVisitor.visitInsn(Opcodes.RETURN); methodVisitor.visitMaxs(1, 1); methodVisitor.visitEnd(); } ClassVisitor cv = new ProfilingClassAdapter(cw, MyMethod.class.getSimpleName()); cr.accept(cv, ClassReader.EXPAND_FRAMES); byte[] bytes = cw.toByteArray(); outputClazz(bytes); Class<?> clazz = new TestMonitor().defineClass("org.itstack.demo.asm.MyMethod", bytes, 0, bytes.length); Method queryUserInfo = clazz.getMethod("queryUserInfo", String.class); Object obj = queryUserInfo.invoke(clazz.newInstance(), "10001"); System.out.println("测试结果:" + obj); } static class ProfilingClassAdapter extends ClassVisitor { public ProfilingClassAdapter(final ClassVisitor cv, String innerClassName) { super(ASM5, cv); } public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { System.out.println("access:" + access); System.out.println("name:" + name); System.out.println("desc:" + desc); if (!"queryUserInfo".equals(name)) return null; MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions); return new ProfilingMethodVisitor(mv, access, name, desc); } } static class ProfilingMethodVisitor extends AdviceAdapter { private String methodName = ""; protected ProfilingMethodVisitor(MethodVisitor methodVisitor, int access, String name, String descriptor) { super(ASM5, methodVisitor, access, name, descriptor); this.methodName = name; } @Override protected void onMethodEnter() { mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false); mv.visitVarInsn(LSTORE, 2); mv.visitVarInsn(ALOAD, 1); } @Override protected void onMethodExit(int opcode) { if ((IRETURN <= opcode && opcode <= RETURN) || opcode == ATHROW) { mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); mv.visitTypeInsn(NEW, "java/lang/StringBuilder"); mv.visitInsn(DUP); mv.visitMethodInsn(INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false); mv.visitLdcInsn("方法执行耗时(纳秒)->" + methodName+":"); mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false); mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false); mv.visitVarInsn(LLOAD, 2); mv.visitInsn(LSUB); mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(J)Ljava/lang/StringBuilder;", false); mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false); mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false); } } }
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
- 87
- 88
- 89
- 90
- 91
- 92
- 93
- 整体的代码块有点大,我们可以分为块来看,如下;
ClassReader cr = new ClassReader(MyMethod.class.getName());
读取原有类,也是字节码增强的开始ClassVisitor cv = new ProfilingClassAdapter(cw, MyMethod.class.getSimpleName());
开始增强字节码onMethodEnter
,onMethodExit
,在方法进入和方法退出时添加耗时执行的代码。
测试结果:
直接运行TestMonitor.java;
access:1
name:<init>
desc:()V
access:1
name:queryUserInfo
desc:(Ljava/lang/String;)Ljava/lang/String;
ASM类输出路径:/E:/itstack/git/github.com/itstack-demo-asm/itstack-demo-asm-03/target/classes/AsmTestMonitor.class
xxxx
xxxx
xxxx
xxxx
方法执行耗时(纳秒)->queryUserInfo:132300
测试结果:10001
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
八、字节码控制打印方法的入参
那么除了可以监控方法的执行耗时,还可以将方法的入参信息进行打印出来。这样就可以在一些异常情况下,看到日志信息。
其他代码与上面相同,这里只列一下修改的地方
static class ProfilingMethodVisitor extends AdviceAdapter { private String methodName = ""; protected ProfilingMethodVisitor(MethodVisitor methodVisitor, int access, String name, String descriptor) { super(ASM5, methodVisitor, access, name, descriptor); this.methodName = name; } @Override protected void onMethodEnter() { mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); mv.visitVarInsn(ALOAD, 1); mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false); } @Override protected void onMethodExit(int opcode) { }
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 从这里可以看到,在方法进入时候使用指令码
GETSTATIC
,获取输出对象类 - 接下来使用
ALOAD
,从局部变量1中装载引用类型值入栈 - 最后输出入参信息
测试结果:
直接运行TestMonitor.java;
Class<?> clazz = new TestMonitor().defineClass("org.itstack.demo.asm.MyMethod", bytes, 0, bytes.length);
Method queryUserInfo = clazz.getMethod("queryUserInfo", String.class);
Object obj = queryUserInfo.invoke(clazz.newInstance(), "10001");
System.out.println("测试结果:" + obj);
- 1
- 2
- 3
- 4
结果;
access:1
name:<init>
desc:()V
access:1
name:queryUserInfo
desc:(Ljava/lang/String;)Ljava/lang/String;
ASM类输出路径:/E:/itstack/git/github.com/itstack-demo-asm/itstack-demo-asm-04/target/classes/AsmTestMonitor.class
10001
...
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
10001
就是我们的方法入参
九、用字节码增强调用外部方法
好!那么执行到这,我们可以想到如果只是将一些信息打印到控制台还是没有办法做业务的,我们需要在这个时候将各种属性信息调用外部的类,进行发送到服务端。比如使用;mq、日志等。
定义日志信息输出类
public class MonitorLog { public static void info(String name, int... parameters) { System.out.println("方法:" + name); System.out.println("参数:" + "[" + parameters[0] + "," + parameters[1] + "]"); }
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 这个类主要模拟字节码增强后,方法调用输出一些信息
增强字节码
static class ProfilingMethodVisitor extends AdviceAdapter { private String name; ... @Override protected void onMethodEnter() { // 输出方法和参数 mv.visitLdcInsn(name); mv.visitInsn(ICONST_2); mv.visitIntInsn(NEWARRAY, T_INT); mv.visitInsn(DUP); mv.visitInsn(ICONST_0); mv.visitVarInsn(ILOAD, 1); mv.visitInsn(IASTORE); mv.visitInsn(DUP); mv.visitInsn(ICONST_1); mv.visitVarInsn(ILOAD, 2); mv.visitInsn(IASTORE); mv.visitMethodInsn(INVOKESTATIC, "org/itstack/demo/asm/MonitorLog", "info", "(Ljava/lang/String;[I)V", false); }
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
-
这里的有一部分字节码操作,其实在增强后最终的效果如下;
public int sum(int i, int m) { Monitor.info("sum", i, m); return i + m; }
- 1
- 2
- 3
- 4
测试结果:
access:1
name:sum
desc:(II)I
signature:null
ASM类输出路径:/E:/itstack/git/github.com/itstack-demo-asm/itstack-demo-asm-05/target/classes/AsmTestMonitor.class
方法:sum
参数:[6,2]
结果:8
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
通过测试内容可以看到,我们已将方法名称与参数信息打印完整。好!到这我们已经基本入门了 ASM
字节码编程的大门,后续还有更多章节,欢迎关注,公众号:bugstack虫洞栈
十、总结
- 高级编程技术的内容还不止于此,不要只为了一时的功能实现,而放弃深挖深究的机会。也许就是你不断的增强拓展个人的知识技能,才让你越来越与众不同。
ASM
这种字节码编程的应用是非常广的,但可能确实平时看不到的,因为他都是与其他框架结合一起作为支撑服务使用。像这样的技术还有很多,比如javaassit
、netty
等等。- 对于真的要学习一样技术时,不要只看爽文,但爽文也确实给了你敲门砖。当你要彻底的掌握某个知识的时候,最重要的是成体系的学习!压榨自己的时间,做有意义的事,是
3-7
年开发人员最正确的事!
文章来源: bugstack.blog.csdn.net,作者:小傅哥,版权归原作者所有,如需转载,请联系作者。
原文链接:bugstack.blog.csdn.net/article/details/105110600
- 点赞
- 收藏
- 关注作者
评论(0)