ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

标星115k+的Java基础教程PDF:从缓存池到String Pool的面试自查指南

标星115k+的Java基础教程PDF:从缓存池到String Pool的面试自查指南

简介:一份源自GitHub标星115k+的Java教程PDF,定位为从零到进阶的完整学习资料,适合正在系统学习Java语法、准备面试或希望夯实基础的开发者。资源按十二个知识主题组织,从byte/char等基本类型与包装类缓存池入手,深入String不可变设计、常量池与new String区别,对比StringBuffer/StringBuilder;接着覆盖自动装箱、值传递与浮点精度、final/static、Object通用方法、继承与访问权限、接口与抽象类、反射、异常、泛型、注解及版本新特性,并附Java与C++对比章节。各主题均配有代码示例与要点总结,易混点如Integer缓存池范围、boolean在JVM中的表示都有明确说明。压缩包内为1个PDF文档,大小约3.26MB,结构清晰、可离线按目录速查。已有893人学习下载,适合作为系统复习、面试突击或日常参考的Java手册。

1. 标星 115k+ 的 Java 教程:这份 PDF 为什么值得照着刷一遍

搜 git java 课程资源的人,大概率都刷到过这份标星 115k+ 的 Java 教程 PDF。它不教框架、不写项目,整本都在啃 Java 基础:数据类型、String、Object 通用方法、关键字、反射、泛型、注解。我开始以为又是八股合集,翻完才发现,目录顺序就是一套复习地图,每个考点都配了能直接跑的代码和反编译字节码。适合两类人:准备面试的从业者,和被零散博客搞得记不住体系的新手。这份资源能帮你把最容易漏的缓存池边界、String Pool、equals/hashCode 一次补齐。

2. 数据类型与缓存池:自动装箱的隐藏调用与 Integer 边界值

2.1 基本类型的大小与 boolean 的特殊存储

教程开头就把基本类型表拉出来了,这张表看着简单,面试里常有坑,尤其是 boolean 那一行。byte 占 8 位、char 和 short 占 16 位、int 和 float 占 32 位、long 和 double 占 64 位,这些是 JVM 规范明确写死的,跨平台不变。boolean 只有 true 和 false 两个值,理论上 1 bit 就能存,但规范没给固定大小,很多教材直接忽略。

真正关键的是 JVM 的编译行为。你在源码里写 boolean 变量,javac 编译时会把 boolean 转成 int,用 1 表示 true、0 表示 false。boolean 数组则特殊一点,不是按 bit 压缩存,而是通过读写 byte 数组来实现。这意味着如果一个 boolean 被频繁读写,实际消耗的是 4 字节栈槽,不是 1 bit。

boolean flag = true; int value = flag ? 1 : 0; // 语言层面不能直接转 int,必须走三元

这段代码说明的是语言层和 JVM 层的差异。Java 语言规范不允许 boolean 和 int 直接互相赋值,但 JVM 内部按 int 处理,所以flag ? 1 : 0只是把编译期已经存在的 int 表示显式化。明白了这一点,再去读 Unsafe 相关代码或做序列化方案选型时,就不会对 boolean 的内存占用产生误判。

2.2 包装类型、自动装箱与 valueOf 的隐藏调用

基本类型不是对象,进不了泛型和集合,所以才有包装类型。自动装箱拆箱让这层转换看起来消失了,但编译器没有变魔法,它在背后插入的是静态方法调用。下面这两行代码,编译后的字节码会明确告诉你代价在哪。

Integer x = 2; // 编译成 Integer.valueOf(2) int y = x; // 编译成 x.intValue()

装箱调用的是Integer.valueOf(int),拆箱调用的是intValue()。因此Integer x = 2得到的对象不一定每次都是新的,它取决于 valueOf 内部是否命中了缓存池。这也引出教程里一个经典对比:

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

new Integer(123)每次都强制在堆里新建一个对象,两个对象地址不同,==自然返回 false。valueOf走的是缓存池,123 在默认范围内,所以 z 和 k 拿到的是同一个对象的引用,==返回 true。这就是为什么包装类型比较一律用equals,而不是==。

2.3 Integer 缓存池:默认范围与 JVM 参数调整

IntegerCache 的下界固定是 -128,上界默认是 127。这个上界不是写死的,JVM 启动时可以通过-XX:AutoBoxCacheMax指定,整个初始化流程在 IntegerCache 的静态块里完成。

static final int low = -128; static final int high; static final Integer cache[]; static { int h = 127; String prop = sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high"); if (prop != null) { try { int i = parseInt(prop); i = Math.max(i, 127); // 下限兜底,不能低于 127 h = Math.min(i, Integer.MAX_VALUE - (-low) - 1); } catch (NumberFormatException e) { // 属性无法解析时忽略,保持默认 127 } } high = h; cache = new Integer[(high - low) + 1]; int j = low; for (int k = 0; k < cache.length; k++) { cache[k] = new Integer(j++); } }

代码里有三个点值得注意。第一,Math.max(i, 127)保证你即使配置 50,最终上界也会被提升回 127,不会出现比默认还小的池子。第二,数组大小是(high - low) + 1,因为区间是闭区间,-128 到 127 一共 256 个数。第三,getSavedProperty是 JDK 8 内部 API,Java 9 模块化后 sun.misc 包不再开放,但机制没变,理解设计思路才是重点。

java -XX:AutoBoxCacheMax=5000 -jar demo.jar

这个参数把 Integer 缓存上界扩大到 5000,适合在业务代码里大量使用 Integer 且数值集中在自定义区间、又不愿多吃内存的场景。需要同时说明两点:一是缓存池只影响valueOf和自动装箱,new Integer(n)不受控制;二是缓存数组在类加载时一次性分配,上界设太大反而浪费内存,先估算热点数值分布再定值。

教程里列了所有基本类型包装类的缓存范围:Boolean 缓存 true/false 两个对象,Byte 缓存全部 256 个取值,Short 缓存 -128 到 127,Character 缓存\u0000到\u007F。只有 Integer 的上界能调,这是 IntegerCache 和其他缓存池最大的区别,也是面试里最容易被追问的细节。

3. String 不可变与 String Pool:从源码看到 intern() 的真实行为

3.1 String 被 final 锁死:char[] 到 byte[] 的存储变化

String 被声明为 final,所以不能被子类继承,Integer 等包装类也一样。但这只是表象,真正保证不可变的是内部存储。JDK 8 里 String 内部用 char 数组,JDK 9 之后改成了 byte 数组加一个 coder 字段,用来标记当前字符串是 LATIN1 还是 UTF16 编码。

// JDK 8 public final class String implements java.io.Serializable, Comparable<String>, CharSequence { private final char value[]; } // JDK 9+ public final class String implements java.io.Serializable, Comparable<String>, CharSequence { private final byte[] value; private final byte coder; }

char 数组每个元素占 2 字节,如果字符串内容全是 ISO-8859-1 能表示的字符,用 byte 数组存就能省一半内存。coder 字段就是用来区分这两种情况的。value 数组声明为 final 只表示引用不能指向别的数组,再加上 String 类内部没有任何修改数组内容的方法,双重保险才让 String 真正不可变。

不可变带来的四个收益,教程讲得很透。第一,hash 值可以缓存,String 经常做 HashMap 的 key,hash 只算一次就够了。第二,String Pool 才能成立,如果字符串可变,池里的引用就会被意外改掉。第三,作为网络连接参数或地址参数时,不会因为持有方的修改导致连接目标漂移。第四,天然线程安全,多个线程共享同一个 String 实例不需要加锁。这四个点我建议背下来,面试问“为什么 String 不可变”时按这个顺序答最稳。

3.2 String、StringBuffer、StringBuilder 的取舍

三者的区别在很多老博客里写得含糊,教程用两句话就说清了:可变性不同、线程安全不同。String 不可变;StringBuffer 内部用 synchronized 同步,线程安全;StringBuilder 不加锁,单线程下更快。

类可变性线程安全适用场景
String不可变天然安全固定内容、常量、Map 的 key
StringBuffer可变synchronized 同步多线程共享的字符串拼接
StringBuilder可变不安全方法内的临时拼接、单线程

实际开发里,方法内的字符串拼接直接上 StringBuilder,因为不会有别的线程访问局部变量,加锁纯属浪费。多线程共享同一个拼接缓冲区才用 StringBuffer,但这种情况在业务代码里很少见,更多是在框架源码里出现。字符串内容固定不变时用 String,性能最好,也最安全。

3.3 String Pool、intern() 与 new String("abc") 的对象数量

String Pool 保存所有字符串字面量,字面量在编译期就确定了。除了字面量,还可以用 intern() 方法在运行时把字符串加入池中。当调用 intern() 时,如果池里已有 equals 相等的字符串,就直接返回池中的引用;否则把当前字符串加入池中,再返回引用。

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 是两个独立堆对象,地址不同。s3 和 s4 都调用 intern(),池中已有 “aaa”,所以拿到同一个池对象引用。s5 和 s6 是字面量创建,编译期就进池,运行时直接复用。结论是:字面量进池是编译期行为,intern() 是运行时行为,两者最终都指向池中的同一份对象。

接着是new String("abc")到底创建几个对象的问题。教程用 javap 反编译给出了证据:

Constant pool: #2 = Class #18 // java/lang/String #3 = String #19 // abc #18 = Utf8 java/lang/String #19 = Utf8 abc public static void main(java.lang.String[]); 0: new #2 // 堆中新建 String 对象 3: dup 4: ldc #3 // 从常量池取字符串对象 6: invokespecial #4 // 调用 String(String) 构造方法 9: astore_1

字节码执行路径是:new 在堆里分配一个空 String 对象,ldc 从常量池取出 “abc” 字面量对象,最后调用构造函数把常量池对象作为参数传进去。所以前提是池中还没有 “abc” 时,一次new String("abc")会创建两个对象:一个是池中的字面量对象,一个是堆中的 String 对象。如果池里已经有 “abc”,堆里那个对象就指向池对象的 value 数组。

再看构造函数的实现:

public String(String original) { this.value = original.value; this.hash = original.hash; }

新旧两个 String 对象共享同一个 value 数组,并不复制字符内容,只是复制引用和 hash。这在 JDK 8 的 char[] 实现下成立,JDK 9 换成 byte[] 后逻辑一致,还是共享数组。这也是为什么用字符串截取甚至 substring 时,在某些版本下子串会持有原串的大数组引用,可能导致内存泄漏,后来的 JDK 版本做了优化,但理解机制仍然重要。

Java 7 之前 String Pool 放在永久代,永久代空间有限,大量字符串时容易 OutOfMemoryError。Java 7 把 String Pool 移到了堆中,这是后面的一个重要变化。它意味着池里的字符串对象和普通对象一样参与 GC,池的大小不再被永久代上限卡死。

4. 参数传递、switch 与 final/static:面试最爱出错的四个考点

4.1 值传递:对象引用拷贝到哪里

Java 只有值传递,没有引用传递。这句话说起来简单,但每次问到“方法里改对象,外部会不会变”仍然会有人答错。关键在于理解:对象变量本身是引用类型,但这个引用是作为值拷贝进方法的。

class Dog { private String name; Dog(String name) { this.name = name; } public String getName() { return name; } public void setName(String name) { this.name = name; } } public class PassByValueExample { public static void main(String[] args) { Dog dog = new Dog("A"); changeName(dog); System.out.println(dog.getName()); // B changeReference(dog); System.out.println(dog.getName()); // 仍然是 B } private static void changeName(Dog dog) { dog.setName("B"); // 形参和实参指向同一个对象,字段修改外部可见 } private static void changeReference(Dog dog) { dog = new Dog("C"); // 只改形参这个引用副本,外部引用不受影响 } }

传给方法的 dog 参数,栈上存的是对象地址的副本。副本和原引用指向同一个 Dog 对象,所以 setName 修改的是同一个对象的字段,外部能看到。但在 changeReference 里让形参指向新对象,只是把副本换了个指向,原引用还在原来的对象上,外部根本不知道发生了这件事。读对象地址时也能验证:方法内外打印toString(),前一种情况地址不变,后一种情况方法里变了、方法外没变。血泪经验就是:想在方法里“替换”外部对象是做不到的,除非把对象包装进数组或一个可变容器传进去,或者方法返回新对象后重新赋值。

4.2 float/double 字面量与复合赋值

Java 不能隐式向下转型,因为会丢精度。字面量 1.1 默认是 double 类型,直接赋值给 float 变量属于从 64 位往 32 位收窄,编译器直接拒绝。这也是新手问得最多的问题之一。

// float f = 1.1; // 编译错误:1.1 是 double 字面量 float f = 1.1f; // 加上 f 后缀才是 float short s1 = 1; // s1 = s1 + 1; // 编译错误:s1 + 1 结果是 int s1 += 1; // 复合赋值自带强转,等价于 s1 = (short)(s1 + 1); s1++; // 同理,内部包含窄化转换

这段代码解释了一个常见困惑。s1 + 1会被提升为 int 运算,结果再赋给 short 属于向下转型,必须显式强转。但+=和++是复合赋值,Java 语言规范明确允许它们内部执行隐式窄化,即使发生溢出也只是截断高位,编译器不报错。这个特性在某些循环计数器上会造成溢出后“绕回”的现象,写代码时要注意数值范围。

4.3 switch 不支持的 type:long、float、double

switch 从 Java 7 开始支持 String,但它一直不支持 long、float、double。教程里给出的解释是设计初衷:switch 面向少数离散值的等值判断,而浮点数有精度问题,long 的取值范围太大,case 分支会膨胀到不可维护。

String s = "a"; switch (s) { case "a": System.out.println("aaa"); break; case "b": System.out.println("bbb"); break; default: break; } // long x = 111; // switch (x) { // 编译报错:Incompatible types // case 111: // break; // }

支持的类型是:char、byte、short、int 以及它们对应的包装类型 Character、Byte、Short、Integer、String 和枚举。编译期会对字符串 switch 做特殊处理,先取 hashCode 定位再逐个 equals 校验,这个特性在第 5 章的避坑里还会继续踩。遇到 float 或 long 的条件分支,老老实实拆成 if-else,可读性比硬塞进 switch 好得多。

4.4 final、static 与初始化顺序

final 修饰数据时,基本类型不能改值,引用类型不能改指向,但引用指向的对象内部字段可以改。这是最容易被忽略的一条:

final int x = 1; // x = 2; // 编译错误 final A y = new A(); y.a = 1; // 合法:没有改 y 指向,改的是 y 指向对象的字段

final 修饰方法表示不能被子类重写,private 方法隐式是 final 的,子类里出现同名同参方法不算重写,而是新定义了一个方法。final 修饰类表示不允许继承。static 则不同,它是类级别的关键字。静态变量在内存中只有一份,所有实例共享;静态方法在类加载时就存在,不依赖任何实例,所以它不能访问实例字段,也不能用 this 和 super,这两个关键字与具体对象关联,在静态上下文里没有意义。

初始化顺序在面试里是必考题,教程给出的规则如下:父类静态变量和静态语句块,再子类静态变量和静态语句块,然后父类实例变量和普通语句块,父类构造函数,最后子类实例变量和普通语句块,子类构造函数。静态部分只执行一次,实例部分每次 new 都重新走一遍。

public class InitialOrderTest { public static String staticField = "静态变量"; static { System.out.println("静态语句块"); } public String field = "实例变量"; { System.out.println("普通语句块"); } public InitialOrderTest() { System.out.println("构造函数"); } public static void main(String[] args) { new InitialOrderTest(); new InitialOrderTest(); } }

输出结果是静态语句块先执行,然后是首次 new 时的实例变量、普通语句块、构造函数;第二次 new 时静态块不再执行,只重复实例初始化和构造部分。写业务代码时,静态块适合放一次性初始化逻辑,比如加载配置、建立缓存,但要注意它是在类第一次被加载时触发,不是在 main 开始时触发。

5. 避坑与常见问题:5 个 Java 基础翻车现场复盘

5.1 Integer 用 == 比较,128 就翻车

现象:两个值相同的 Integer 用==比较,127 时返回 true,换成 128 就返回 false,逻辑明明是同一个数值。

Integer a1 = 127; Integer b1 = 127; System.out.println(a1 == b1); // true Integer a2 = 128; Integer b2 = 128; System.out.println(a2 == b2); // false

原因:自动装箱走的是valueOf,valueOf会先查 IntegerCache。127 在默认缓存范围 -128 到 127 内,两个变量拿到同一个缓存对象,地址相同所以==为 true。128 超出缓存范围,valueOf 只能各自 new 一个 Integer,地址不同,==自然为 false。

解决:包装类型比较一律用equals,或者拆箱成 int 后比较。需要更稳妥的写法可以用Objects.equals(a, b),它会先处理 null,避免 NPE。不要依赖缓存池范围判断业务逻辑,即使你在启动参数里调大了 AutoBoxCacheMax,换一台机器、换一个 JDK 版本都可能变。

5.2 字符串循环拼接,慢到怀疑机器

现象:在循环里用s += i拼字符串,数据量一上来,接口耗时从几十毫秒涨到几秒,GC 频繁,甚至出现明显卡顿。

原因:String 不可变,每次+=都会在堆里创建一个新的 String 对象,并拷贝原字符串内容。循环 N 次的代价接近 O(N²) 的字符拷贝,加上每次新建对象,GC 压力直接拉满。

long start = System.currentTimeMillis(); String s = ""; for (int i = 0; i < 50000; i++) { s += i; // 每次生成新的 String,O(N²) 级拷贝 } long cost = System.currentTimeMillis() - start; StringBuilder sb = new StringBuilder(100000); // 预估容量,减少扩容 for (int i = 0; i < 50000; i++) { sb.append(i); } String result = sb.toString();

上面这段简单计时不是严谨压测,但差距足以说明问题。StringBuilder 内部用可变数组,append 只在扩容时拷贝一次,扩容次数由初始容量决定。参数100000就是初始容量,单位是字符数,按预估结果长度设置,能避免多次数组扩容。

解决:方法内临时拼接用 StringBuilder,循环外预估容量;多线程共享缓冲区需要线程安全就用 StringBuffer;内容固定不变直接写 String。JDK 编译器对"a" + "b"这种常量拼接会优化,但循环体内的动态拼接不会,别指望编译器兜底。

5.3 equals 没配 hashCode,HashSet 出现重复对象

现象:自定义类重写了 equals,两个实例 equals 返回 true,放进 HashSet 后 size 却是 2,去重失败。

原因:HashSet 底层借助 HashMap,先通过 hashCode 计算桶位置,落到同一桶后才用 equals 确认。没有重写 hashCode 时,继承自 Object 的哈希方法是根据对象地址生成的,两个内容相同的对象哈希值不同,根本进不了同一个桶,equals 再相等也没机会被调用。

EqualExample e1 = new EqualExample(1, 1, 1); EqualExample e2 = new EqualExample(1, 1, 1); System.out.println(e1.equals(e2)); // true,因为重写了 equals HashSet<EqualExample> set = new HashSet<>(); set.add(e1); set.add(e2); System.out.println(set.size()); // 2,因为没重写 hashCode

解决:覆盖 equals 必须同时覆盖 hashCode,保证等价对象哈希值一定相等。实现时可以按对象关键字段计算散列值,常见做法是取一个素数作为初始值,逐字段叠加:int result = 17; result = 31 * result + x;为什么取 31,因为它是奇素数,偶数做乘法溢出时会丢失最高位信息,31 又能被编译器优化成移位减法的形式,兼顾分布和质量。

5.4 switch 传入 null,直接抛 NPE

现象:switch 的表达式是 String,运行时值却是 null,代码在 switch 行直接抛 NullPointerException,业务状态都没来得及判断。

原因:字符串 switch 编译后不是简单的跳转表,而是先对表达式调用hashCode()定位候选 case,再逐个调用equals校验。表达式为 null 时,调用 hashCode 本身就触发 NPE。

String input = null; switch (input) { // 这一行抛 NullPointerException case "a": break; case "b": break; default: break; }

解决:进入 switch 前先判空,或者改用 if-else 搭配Objects.equals(input, "a")。如果分支值适合 switch 的等值判断,优先在入口处做input == null拦截,再走 switch,既保持代码结构清晰,又不丢 NPE 防护。

5.5 静态变量用实例访问,改一个全变

现象:一个实例修改了静态计数字段,发现其他实例都读到新值,数据互相覆盖。上线后排查半天,最后发现字段修饰符是 static。

public class User { public static int count = 0; } User user1 = new User(); User user2 = new User(); user1.count++; user2.count++; System.out.println(User.count); // 2,不是 1

原因:静态变量属于类,内存中只有一份,所有实例共享。通过实例访问静态变量的写法容易造成错觉,让维护者误以为是实例字段,定位问题时方向就错了。解决:静态字段永远通过类名访问,代码里出现user1.count这种写法时要警觉。状态与具体对象绑定就用实例变量,状态与类绑定才用 static,这是最基础的职责划分。

6. 把教程改造成面试自查清单:间隔重复卡片怎么做

把这份 PDF 刷一遍不难,难的是刷完不还回去。我的做法是不把它当书看,而是拆成一份检查清单。教程目录本身就是现成的骨架:数据类型、String、运算、关键字、Object 通用方法、继承、反射、异常、泛型、注解、新特性,每一个大节对应一组自查题。

第一步,把目录转成 Markdown 清单,每个小节抽考点。比如第 2 章可以抽出:基本类型大小表、boolean 的 JVM 存储、自动装箱拆箱的编译调用、Integer 缓存池默认范围、AutoBoxCacheMax 参数作用。第二步,把这些考点做成隔一天复习一次的卡片,卡片正面是问题,背面写结论加关键代码。第三步,给自己设一个通过线:不看代码,能在脑子里说清为什么 128 的==是 false,String Pool 和 intern() 的对象创建路径,switch 为什么收不了 long。说得明白就过,说不明白就重新做卡片。

我一般会在卡片工具里建一个“Java 基础错题集”,把每次答错的考点原样记录,格式就两张字段:错在哪、下次怎么记。表格可以做成这样:

考点自测题易错点
Integer 缓存池128 的 Integer == 比较输出什么超出 -128~127 范围会 new 新对象
String internnew String("abc") 产生几个对象池中有字面量对象,堆中有业务对象
equals/hashCode重写 equals 不重写 hashCode 的后果HashSet 会把等价对象当成两个元素

这份 PDF 我前后刷了三遍,目录快背下来了,但收获最大的不是答案本身,而是它把易混点放在一起对比的写法:缓存池和 String Pool 一起看,final 和 static 一起看,equals 和 hashCode 一起看。从那以后我每次面试前都强制走一遍自查清单,把所有答错的题重新排队进卡片列表,一周之后再滤一遍,基本就能把 Java 基础压到短期记忆里不再反弹。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进