简介:GitHub 上标星 115k+ 的 Java 教程 PDF,是一份面向 Java 入门与进阶学习者的系统性基础文档,覆盖数据类型与包装类型缓存池、String 不可变设计、运算与值传递、final/static 关键字、Object 通用方法、继承访问权限、反射、异常、泛型、注解以及 Java 各版本新特性等核心主题,并专门对比了 Java 与 C++ 的语言差异与内存管理方式,既可配合课程学习,也适合作为笔试面试前快速回顾知识点的手册。资源为单个 PDF 文件,压缩包大小仅 3.26MB,按章节组织、定位方便,可离线阅读或在手机、平板等设备上随时查阅;内容在常见语法之外,还通过缓存池、String Pool、自动装箱拆箱等易错细节解释底层机制,并穿插典型代码片段,帮助读者建立从基础概念到实际应用的完整链路。目前已有 893 人学习浏览,尤其适合正在夯实 Java 基础、准备求职面试或希望系统梳理知识体系的开发者。
1. 一份标星 115k+ 的 Java 教程:先挑三个高频考点验证再读书
GitHub 上标星 115k+ 的 Java 教程,一搜 Java、课程资源、基础面试,总能碰到这份 PDF。它不是从头讲到尾的教材,而是一份「Java 基础考点清单」:数据类型、String、运算、关键字、Object 通用方法、继承、反射、异常、泛型、注解,全部压缩成可以直接背出来答题的结构化条目。我拿到后没有按顺序翻,而是先挑最容易考翻车的三块——Integer 缓存池、String Pool、值传递——各写了几行验证代码,跑完才发现 PDF 里两三行的结论,展开后全是坑。这份资源适合两类人:准备后端基础面、想把零散概念变成答题话术的开发者;学了 Java 一段时间但说不清「为什么」的从业者。
2. 数据类型与缓存池:从 -128~127 到 AutoBoxCacheMax 的边界实验
先说结论:整型包装类型的==比较,只有在缓存范围内才是「看起来相等」,越界就是两个对象,这个边界面试百考不厌,线上也容易翻车。PDF 里给了 valueOf 的源码,我建议你按下面步骤自己跑一遍,印象比背答案深得多。
2.1 基本类型与包装类型:boolean 的大小为什么是「玄学」
基本类型位宽是死记硬背的考点,但 PDF 里给了 Java 官方没有明确规定 boolean 位宽的事实,这里值得展开。基本类型分布如下:
| 类型 | 位宽 | 默认值 | 典型场景 |
|---|---|---|---|
| byte | 8 | 0 | 文件流、协议头 |
| short | 16 | 0 | 网络包长度 |
| int | 32 | 0 | 默认整数、循环计数 |
| float | 32 | 0.0f | 科学计算(不追求高精度) |
| long | 64 | 0L | 时间戳、ID、大数值 |
| double | 64 | 0.0d | Math 库默认浮点类型 |
| char | 16 | '\u0000' | 单个字符、Unicode BMP 内 |
| boolean | 未规定 | false | 开关状态 |
boolean 后面不写位宽不是笔误,JVM 规范确实没规定 boolean 占几位。实际实现是:编译期把 true/false 转成 int 的 1/0,一维 boolean 数组则通过读写 byte 数组完成,baload/bastore指令直接对应 byte 数组操作。验证方式很简单,写个最小类再 javap:
# TestBoolean.java public class TestBoolean { public static void main(String[] args) { boolean flag = true; if (flag) { System.out.println("true"); } } }javac TestBoolean.java javap -c TestBoolean关键字节码行是0: iconst_1和1: istore_1:iconst_1 把常量 1 压栈,istore_1 存入局部变量表,说明 true 在指令层面就是一个 int 1。
这段验证说明两点:一是语言层 boolean 只有 true/false 两个值,二是 JVM 实现层把它统一成 int/byte 处理。所以做内存估算时,单个 boolean 字段不能直接按 1 bit 算,对象头、字段对齐、运行时布局都会让它膨胀,这也是网上总说 boolean 大小是玄学的原因。
2.2 自动装箱与 valueOf:把缓存池边界跑一遍
自动装箱发生在赋值或传参时,Integer x = 2;编译器会改成Integer.valueOf(2);拆箱则是int y = x;被改成x.intValue()。这个转换看起来无感,但 valueOf 内部不是每次 new:
public static Integer valueOf(int i) { if (i >= IntegerCache.low && i <= IntegerCache.high) return IntegerCache.cache[i + (-IntegerCache.low)]; return new Integer(i); }逻辑很直白:值落在缓存区间,就返回缓存数组里同一个对象;越界才 new。PDF 里 javap 验证 new String 的手法,同样可以用在包装类型上,直接跑下面的等价性测试:
public class IntegerCacheDemo { public static void main(String[] args) { Integer x = new Integer(123); Integer y = new Integer(123); System.out.println(x == y); // false Integer z = Integer.valueOf(123); Integer k = Integer.valueOf(123); System.out.println(z == k); // true Integer m = 123; // 自动装箱 -> valueOf(123) Integer n = 123; System.out.println(m == n); // true Integer p = 128; Integer q = 128; System.out.println(p == q); // false } }输出依次是 false、true、true、false。
逻辑说明:==比较引用时比的是堆地址,new Integer强制新建,地址必不同;valueOf 命中的是同一个 cache 数组槽位,地址相同;128 越过了默认上界 127,valueOf 返回两个新对象,所以又变 false。Java 8 里 Integer 缓存范围是 -128~127,这个区间由 JLS 7 5.1.7 保证必须驻留,不是某个虚拟机实现的私货。
补充一个血泪经验:Java 9 起new Integer(123)已被标记废弃,新工程直接写Integer.valueOf(123)或Integer x = 123,编译器不报警告,语义也更清晰。
2.3 调 AutoBoxCacheMax:扩大 Integer 缓存池的实际场景
如果业务里大量使用Integer.valueOf()包装状态码/枚举值,并且多处用==做比较,缓存上界 127 就不够用。JDK 留了一个开关,启动参数:
java -XX:AutoBoxCacheMax=10000 -jar order-service.jar原理:JVM 初始化时把参数写入名为java.lang.IntegerCache.high的系统属性,IntegerCache 静态块通过sun.misc.VM.getSavedProperty()读取并重设上界。等价写法是-Djava.lang.IntegerCache.high=10000,两者殊途同归,我一般优先用-XX:AutoBoxCacheMax,语义更明确。
参数说明:
- low 固定 -128,不可调;
- high 最大不能超过
Integer.MAX_VALUE - (-low) - 1,避免数组长度越界; - 扩大缓存会一次性初始化对应长度的 Integer 对象数组,调太高时注意内存占用,例如调到 10000 会多出约一万个对象;
- IntegerCache 只在类首次初始化时构建一次,运行中改参数不生效,必须重启 JVM。
提示:这个参数只对 Integer 有效。byte 全部驻留、short 固定 -128~127、char 固定 \u0000~\u007F、Boolean 只有 TRUE/FALSE 两个常量,各自的缓存范围由 JLS 固定,没有可调开关。
这一章通篇和源码对应着看,你会发现 PDF 给的 valueOf 源码不是随便贴的,它是理解缓存池的钥匙。
3. String 与字符串常量池:三个容易记错的相等判断场景
String 是面试出现率最高的类,没有之一。PDF 从不可变设计讲到 String Pool,再到 intern(),每一节都能单独拎出来出题。下面按「为什么不可变 → 三个实现选谁 → new String 到底建几个对象」的顺序过一遍。
3.1 不可变设计:为什么 String 能安全做 HashMap 的 key
Java 8 的 String 内部就是private final char value[];,Java 9 起改成private final byte[] value;并增加private final byte coder;标记 LATIN1/UTF16 编码。value 被 final 修饰,且类里没有任何方法能修改 value 指向的数组内容,所以 String 一创建内容就固定了:
public final class String implements java.io.Serializable, Comparable<String>, CharSequence { private final char value[]; // Java 8 }public final class String implements java.io.Serializable, Comparable<String>, CharSequence { private final byte[] value; // Java 9+ private final byte coder; }不可变带来的四个好处,PDF 讲得很清楚:
- 能缓存 hash 值。String 常被用作 HashMap/HashSet 的 key,hashCode 只算一次,之后取缓存,查询效率有保证;
- String Pool 才能成立。只有内容不可变,池里才能安全复用同一个对象;
- 安全性。String 广泛用作网络连接参数、文件路径、类加载入口,若可变,调用方传出去后被改,行为无法预期;
- 线程安全。不可变对象天然可以在多线程间共享,不需要加锁。
其中第 3 点最常被忽略。一个连接参数如果是可变的,另一个线程把 host 改了,当前线程以为在连 A 服务器,实际连到了 B,这种隐患在安全审计里属于高危。正因为这个理由,对外接口的不可变字段一律用 String 而不是自定义可变字符串类型。
3.2 String、StringBuffer、StringBuilder:三个类的选型边界
三者的关系一句话就能说清:String 不可变,StringBuilder 可变但线程不安全,StringBuffer 可变且方法级加锁。
| 类 | 可变性 | 线程安全 | 适用场景 |
|---|---|---|---|
| String | 不可变 | 安全 | 字面量、拼接不频繁、作为 key/参数 |
| StringBuffer | 可变 | 安全(synchronized) | 多线程共享同一个缓冲并频繁拼接 |
| StringBuilder | 可变 | 不安全 | 单线程拼接,性能最优 |
选型理由不要只背「线程安全」这句话。StringBuffer 每个 append 都进 synchronized 方法,锁粒度是整个方法;如果拼接动作本身发生在局部变量里,根本没有跨线程共享,加锁就是白付开销,常见做法是直接上 StringBuilder。反过来,如果确实有一个 StringBuilder 被多个线程 append,StringBuilder 会丢数据甚至数组越界,此时才轮到 StringBuffer。
可以跑个简单对比验证趋势,不用严谨基准,只看量级:
public class StringConcatBench { public static void main(String[] args) { int n = 100000; long start = System.nanoTime(); String s = ""; for (int i = 0; i < n; i++) { s += i; } long end = System.nanoTime(); System.out.println("String +=: " + (end - start) / 1_000_000 + " ms"); start = System.nanoTime(); StringBuilder sb = new StringBuilder(); for (int i = 0; i < n; i++) { sb.append(i); } end = System.nanoTime(); System.out.println("StringBuilder: " + (end - start) / 1_000_000 + " ms"); } }逻辑说明:s += i每次循环都 new 一个 String 并把旧内容全部复制一遍,整体 O(n²);StringBuilder 在内部 char[]/byte[] 上追加,容量不够时翻倍扩容,整体接近 O(n)。100000 次循环足够拉开几十倍差距。实测时用System.nanoTime(),毫秒精度在小数据量下看不出差异。
提示:别在循环里做字符串
+=,也不要为了「保险」一律用 StringBuffer。先确认变量是否逃逸出当前线程,再决定用哪个。
3.3 String Pool 与 intern():new String("abc") 到底建了几个对象
String Pool 保存编译期确定的所有字符串字面量;运行期想加入动态字符串,要调 intern()。先看结论代码:
public class InternDemo { public static void main(String[] args) { String s1 = new String("aaa"); String s2 = new String("aaa"); System.out.println(s1 == s2); // false String s3 = s1.intern(); String s4 = s2.intern(); System.out.println(s3 == s4); // true String s5 = "bbb"; String s6 = "bbb"; System.out.println(s5 == s6); // true } }s1 与 s2 指向堆上两个不同对象,==是 false;intern() 去池里找 "aaa",找到就返回池对象引用,所以 s3 和 s4 拿到同一个引用;字面量 "bbb" 在编译期就进池,s5、s6 本来就是同一个对象。这份代码建议亲手跑一次,再配合反编译看 new String("abc") 的开销:
# NewStringTest.java public class NewStringTest { public static void main(String[] args) { String s = new String("abc"); } } javap -verbose NewStringTest常量池关键行:
#2 = Class #18 // java/lang/String #3 = String #19 // abc #19 = Utf8 abc反编译结果说明:new #2在堆里 new 一个 String 实例,ldc #3从常量池加载 String Pool 里的 "abc" 对象作为构造参数。因此,在「池中还没有 abc 字符串」的前提下,new String("abc") 创建了两个对象:一个在 String Pool,一个在堆。如果池里已经有 "abc",new 只创建堆上那一个。
PDF 里还给了个历史知识点:Java 7 之前 String Pool 放在永久代(PermGen),空间有限,大量 intern 容易 OutOfMemoryError;Java 7 起移到堆,才缓解了这个问题。这也解释了为什么老教程会警告「别滥用 intern()」——不是 intern 本身有问题,而是永久代放不下。
最后一个容易踩的坑:intern() 在现代 JDK 上不保证每次调用都会把字符串放入池中,字符串去重、G1 的字符串去重等优化会在底层影响驻留策略。面试背「intern 入池」结论没问题,线上想靠 intern 省内存,先做压测再上线。
4. 运算、参数传递与关键字:把地址打印出来看真相
「Java 是值传递还是引用传递」是网上吵了十几年的题。PDF 用一段 Dog 代码把结论钉死了:Java 只有值传递,对象引用本身也是按值传递的副本。这一章顺便把 float/double 精度、复合赋值和初始化顺序一起讲掉。
4.1 值传递真相:改字段与改引用是两回事
先看改字段版本:
public class Dog { String name; Dog(String name) { this.name = name; } void setName(String name) { this.name = name; } public static void main(String[] args) { Dog dog = new Dog("A"); func(dog); System.out.println(dog.getName()); // B } private static void func(Dog dog) { dog.setName("B"); } }再对照改引用版本:
public class PassByValueDemo { public static void main(String[] args) { Dog dog = new Dog("A"); System.out.println(dog); // Dog@4554617c func(dog); System.out.println(dog); // Dog@4554617c System.out.println(dog.getName()); // A } private static void func(Dog dog) { System.out.println(dog); // Dog@4554617c dog = new Dog("B"); System.out.println(dog); // Dog@74a14482 System.out.println(dog.getName()); // B } }两个版本连起来看就通了:变量 dog 存的是对象地址,传参时把地址的副本传给形参。第一个版本里,形参和实参指向同一个堆对象,setName改的是对象内容,所以外部看到 B;第二个版本里,形参被重新赋值指向新对象,但实参的地址副本没变,外部 dog 还是原来的 A。地址打印一致,证明形参副本指向同一个对象;第二次地址变了,证明只是副本换了指向。
常见误用是拿「对象是引用类型」推导出「引用传递」。真要是引用传递,第二个版本的形参重新赋值后,外部 dog 也应该指向新对象。实测没有,说明 Java 就是把地址当值在传。写接口时尤其注意:方法内尽量别给入参重新赋值,容易误导调用方。
4.2 float/double 与复合赋值:为什么 s1 += 1 能过编译
PDF 给了一段反直觉代码:
// float f = 1.1; // 编译错误: 不兼容的类型,1.1 是 double float f = 1.1f; short s1 = 1; // s1 = s1 + 1; // 编译错误: int 不能隐式转 short s1 += 1; // 通过 s1++; // 通过逻辑说明:字面量 1.1 默认是 double 类型,直接赋给 float 属于向下转型,Java 不允许隐式降低精度;加 f 后缀才是 float。short 参与运算时先被提升为 int,s1 + 1结果是 int,往回赋 short 又是向下转型,所以编译报错。而复合赋值+=和自增++会隐式完成窄化转换,语义等价于:
s1 = (short) (s1 + 1);设计意图是「程序员写复合赋值时已经表达了对类型窄化的接受」,但这也正是 bug 高发点:s1 很短时没问题,超过 short 上限就溢出。我一般在代码评审里看到+=都会多问一句:这个变量的值域确认过吗?
另外 switch 的边界也常考:从 Java 7 起 switch 支持 String,但始终不支持 long、float、double。PDF 的解释很到位——switch 设计初衷是对「少数几个值」做等值判断,long/浮点值的取值范围太大,适合 if-else 而不是跳转表。
4.3 final、static 与初始化顺序:继承场景的完整执行链
final 覆盖三个层面:
- 数据:基本类型值不可变;引用类型引用不可变,但对象内部字段仍可改(
final A y = new A(); y.a = 1;合法); - 方法:不能被子类重写,private 方法隐式 final,子类写同名私有方法只是新方法;
- 类:不允许被继承。
static 的几个形态里,面试最常追问的是「静态导入」「静态内部类」和「初始化顺序」。静态内部类不依赖外部类实例,直接用new StaticInnerClass()创建;非静态内部类必须用outerClass.new InnerClass(),因为内部类持有外部类 this 引用。静态方法不能是 abstract、不能访问实例字段、不能使用 this/super,因为静态方法在类加载阶段就存在,那时实例还没创建。
初始化顺序用一段带继承的代码一次验证:
public class InitBase { static { System.out.println("base static block"); } { System.out.println("base instance block"); } public InitBase() { System.out.println("base constructor"); } }public class InitChild extends InitBase { static { System.out.println("child static block"); } { System.out.println("child instance block"); } public InitChild() { System.out.println("child constructor"); } public static void main(String[] args) { new InitChild(); } }执行输出:
base static block child static block base instance block base constructor child instance block child constructor执行链分两段记:类加载阶段,父类静态优先于子类静态,只执行一次;实例化阶段,先父类实例块+构造器,再子类实例块+构造器。静态块早于实例块,实例块早于构造器。想在构造器之前给字段做统一赋值,就放在实例块里;想只做一次全局初始化,就放静态块。
这段在框架源码里很常见:工具类、配置类大量依赖静态块做启动时初始化,理解顺序才能解释「为什么静态块里访问不到实例字段」。
5. 避坑排查:equals、hashCode、clone 必须一起覆写
Object 通用方法看起来简单,实际翻车全在细节。PDF 把 equals 的五个等价条件、hashCode 的 31 进制算法、clone 的 protected 限制都列了一遍,我这里按「现象 → 原因 → 解决」整理成 4 条排查记录,每条都是实际踩过的。
5.1 现象:HashSet 出现两个「相等」对象
现象:定义了一个只覆写 equals 的类,添加两个字段完全相同的实例到 HashSet,set.size()结果是 2,而不是期望的 1。
原因:HashSet 依赖 hashCode() 定位桶,再在同一桶内用 equals 比较。两个对象 hashCode 不同,直接被分到不同桶,equals 根本没机会执行。PDF 里 EqualExample 就是这个例子的标准版:
public class EqualExample { private int x, y, z; public EqualExample(int x, int y, int z) { this.x = x; this.y = y; this.z = z; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; EqualExample that = (EqualExample) o; return x == that.x && y == that.y && z == that.z; } }解决:覆写 equals 的同时必须覆写 hashCode,保证「等价对象哈希值必相等」:
@Override public int hashCode() { int result = Integer.hashCode(x); result = 31 * result + Integer.hashCode(y); result = 31 * result + Integer.hashCode(z); return result; }补上之后,两个等价对象算出的散列值相同,HashSet 第二个对象落到同一个桶,equals 才会返回 true,size 变回 1。契约一句话:equals 相等 ⇒ hashCode 相等;hashCode 相等 ⇏ equals 相等。
5.2 现象:自己写的 hashCode 碰撞率奇高
现象:自定义类 hashCode 直接把所有字段的值加在一起,或者用 30 做乘子,结果 HashMap 查询链表很长,性能掉到秒级。
原因:PDF 给了数学解释——用 R 进制组成一个整数,R 一般取 31。31 是奇素数:奇数乘法溢出时信息不会像偶数那样整体左移丢位;31 又与编译器优化契合,31 * x可以改写成(x << 5) - x,左移 5 位再减原值,JIT 会采用这个等价形式,不用真正做乘法。用偶数做乘子时,乘法溢出相当于左移一位,最左边的位直接丢失,信息损失更严重。
解决:通用模板是 result 从 17 起,每参与一个域就31 * result + fieldValue,浮点用 Float.floatToIntBits/Double.doubleToLongBits 转成位模式再参与:
@Override public int hashCode() { int result = 17; result = 31 * result + x; result = 31 * result + y; result = 31 * result + z; return result; }5.3 现象:外部调 clone() 编译报 protected 访问错误
现象:写了一个普通类,实例化后在另一个包调用obj.clone(),编译报clone() has protected access in Object。
原因:clone() 是 Object 的 protected 方法,不在同包、不是子类就访问不到。一个类不显式重写 clone(),外部就没有合法的调用入口。
解决:类实现 Cloneable 并重写 clone 为 public,方法体调 super.clone():
public class CloneDemo implements Cloneable { private int id; @Override public CloneDemo clone() throws CloneNotSupportedException { return (CloneDemo) super.clone(); } }解决后还要留意浅拷贝:如果类里有引用类型字段,例如private int[] scores;,super.clone() 返回的对象里 scores 仍指向原对象同一个数组,改副本会连累原件。需要手动深拷贝:
@Override public CloneDemo clone() throws CloneNotSupportedException { CloneDemo copy = (CloneDemo) super.clone(); copy.scores = this.scores.clone(); // 数组单独复制 return copy; }对象里嵌了 List、Map 之类,光 clone 数组不够,要自己逐个复制容器元素,这就是为什么现在很多项目干脆用构造器拷贝或者序列化拷贝,省得在深拷贝上纠结。
5.4 现象:equals 里用 instanceof 还是 getClass() 拿不准
现象:父类 A 与子类 B,B 扩展了字段,用 instanceof 判断类型的 equals,A 对象和 B 对象可能互相 equals,但 B 多出来的字段被忽略。
原因:instanceof 允许子类实例通过类型检查,然后强转成父类比较;getClass() 要求运行时类完全一致。两者的语义目标不同:
- getClass() 严格:只有同类才等价,适合「类就是身份」的值对象;
- instanceof 宽松:允许子类共享父类的等价关系,适合父子类都只有同一组语义字段的场景。
解决:定义一个类时先想清楚它允不允许子类扩展。类声明为 final 或枚举,直接用 getClass();需要保留子类扩展空间且等价关系不因扩展字段改变,用 instanceof 并在子类里重写 equals 判断所有父类字段。
| 判断方式 | 子类能否通过 | 等价关系 | 适用场景 |
|---|---|---|---|
| getClass() != o.getClass() | 否 | 同类才相等 | final 类、枚举、值对象 |
| o instanceof X | 是 | 父类字段相等 | 允许子类参与比较且不扩展关键字段 |
这四条串起来是一条主线:equals/hashCode/clone 三个方法不是孤立的,它们一起决定了对象作为值、作为 key、作为副本时的行为。覆写时成套处理,别只改一个。
6. 把反射、泛型、注解变成项目工具:一个字段校验器
PDF 后半段把反射、异常、泛型、注解、Java 各版本新特性、Java 与 C++ 对比列成了清单,单看很容易变成「背概念」。我习惯挑三个知识点组合成一个小工具写一遍,反射 + 注解就非常适合做 DTO 字段校验,能把每个接口都重复写的那堆 if 收敛掉。
思路是:自定义一个运行时注解描述字段规则,再用反射读取并校验。先看注解定义:
import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface FieldCheck { boolean required() default false; int maxLength() default -1; }@Target 限定只能标在字段上;@Retention 必须选 RUNTIME,否则反射拿不到。校验器遍历字段、跳过无注解字段、读取规则并抛出异常:
import java.lang.reflect.Field; public class FieldValidator { public static void validate(Object obj) throws IllegalAccessException, IllegalArgumentException { for (Field field : obj.getClass().getDeclaredFields()) { field.setAccessible(true); FieldCheck check = field.getAnnotation(FieldCheck.class); if (check == null) continue; Object value = field.get(obj); if (check.required() && value == null) { throw new IllegalArgumentException(field.getName() + " 不能为空"); } if (value instanceof String && check.maxLength() >= 0 && ((String) value).length() > check.maxLength()) { throw new IllegalArgumentException(field.getName() + " 超过最大长度"); } } } }getDeclaredFields 只返回当前类声明的字段,继承来的字段拿不到;setAccessible(true) 是为了读取 private 字段。字段校验只是反射的入门用法,同样思路可以做日志脱敏、权限标记、枚举映射。
使用示例:
public class DemoDto { @FieldCheck(required = true, maxLength = 32) private String name; public static void main(String[] args) throws Exception { DemoDto dto = new DemoDto(); FieldValidator.validate(dto); // 抛出 IllegalArgumentException: name 不能为空 } }泛型在这个工具里也有参与:如果把 DemoDto 放进 List 批量校验,List 的泛型保证编译器只允许元素类型正确的列表通过,避免循环里强转出错。值得注意的是 Java 泛型运行期会擦除,反射拿不到 List 里的具体类型,所以校验器只接受 Object 参数,由调用方负责类型安全。
这套组合也是面试常问的「运行时注解是怎么生效的」。从那以后,我每次拿到新的技术 PDF 都会挑三个能在半小时内组合成工具的知识点直接写一遍,反射加注解就是这样从黑匣子变成习惯用法的。希望帮到你。
本文还有配套的精品资源,点击获取