聊聊 Java 基础里那些容易被忽略的东西
聊聊 Java 基础里那些容易被忽略的东西
学 Java 的人几乎都从同一套东西入门:变量、循环、面向对象、集合。但很多人写了三五年代码,被问到"String 为什么不可变"、"HashMap 怎么扩容"这种问题时,还是答不利索。这篇就把我自己踩过的坑和想明白的事整理出来,不追求面面俱到,但每个点都尽量说透。
JDK、JRE、JVM 到底谁包谁
这仨的关系一句话就能说清:JDK 包含 JRE,JRE 包含 JVM。
JVM 是干活的,负责加载字节码、校验、执行、管内存。JRE 就是 JVM 加上一堆核心类库,装了这个就能跑 Java 程序。JDK 再加上开发工具,比如 javac、jconsole 这些。
有个事值得想一下:JVM 执行的是 class 文件,不是 java 源码。所以 Kotlin、Scala 之类的语言能跑在 JVM 上一点都不奇怪,JVM 从来就不是给 Java 独享的。
基本类型和引用类型,差的不是语法是内存
int 存的是值,Integer 存的是地址。这句话听着像废话,但坑都从这里来。
最基本的区别:基本类型赋值是拷贝一份,你改你的我改我的;引用类型赋值是复制地址,两个变量操作的是堆里同一个对象。
然后是经典的 127 和 128 的问题:
Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println(a == b); // true
System.out.println(c == d); // false
原因是 Integer 内部有个缓存池,缓存了 -128 到 127 的对象。a 和 b 拿到的是同一个缓存对象,c 和 d 超出范围只能各自 new。所以包装类型之间比较,老老实实用 equals,别指望 ==。
String 为什么要设计成不可变
我以前觉得这是 Java 的"规矩",后来才明白这是深思熟虑的设计。
第一,为了常量池。字符串是程序里出现最频繁的数据,如果 “hello” 每次出现都在堆里新建一个对象,内存早就炸了。常量池能让相同字面量全局共享,但共享的前提是不可变——不然我这边把 “hello” 改成 “hack”,你那边引用着同一个对象,直接出事。
第二,线程安全。不可变对象随便在多线程之间传,不需要任何同步措施。
第三,hashCode 缓存。String 的 hash 算一次就存起来,作为 HashMap 的 key 时不用每次重算。这也是为什么 HashMap 的 key 用 String 特别顺手的底层原因。
顺带说一句,循环里拼接字符串用 StringBuilder,不是编译器会帮你优化的 + 号。每次 + 都会 new 一个新对象,循环一百次就是一百个中间垃圾。
equals 和 hashCode 是绑定关系
面试常问,实际踩坑更多。
equals 默认实现就是 ==,也就是比地址。你重写了它,改变了"什么算相等"的语义,但 HashMap 存取数据时第一步是用 hashCode 定位到某个桶,第二步才在桶里用 equals 逐个比对。
如果你只重写 equals 不重写 hashCode,会出现一个很隐蔽的 bug:两个"相等"的对象,hashCode 不同,被放进了不同的桶。然后你拿着一个相等的对象去 contains,桶都找错了,equals 根本没机会执行,返回 false。你盯着代码看半天都发现不了问题在哪。
所以记住:重写 equals,必须同时重写 hashCode,且相等的对象必须有相同的哈希值。
多态:编译看左边,运行看右边
Animal a = new Dog();
a.makeSound(); // 执行的是 Dog 的实现
Animal a = new Dog(); 这行代码里,编译器看的是 Animal,所以 Animal 没有的方法你调不了,编译直接报错。但真正执行的时候,JVM 看的是 Dog 这个实际类型,走的是虚方法表找到 Dog 的实现。
要注意的是 static、private、final 方法不参与多态,它们在编译期就绑定了。所以"父类 static 方法被子类同名方法覆盖"是个假象,那只是隐藏,不是重写。
至于继承本身,说句实话:能用组合就别用继承。继承是所有面向对象关系里耦合最强的一种,子类天然依赖父类的实现细节,父类一改子类可能跟着崩。接口表达"能做什么",继承表达"是什么",大部分时候你需要的其实是前者。
接口和抽象类怎么选
以前我的判断标准很粗糙:需要多实现就用接口,其他的用抽象类。后来觉得更好的思考方式是这样——
接口定义的是契约,是一份"你必须能做这些事"的承诺,它不关心你是谁。抽象类是在抽取一组类的公共部分,把重复代码和公共状态收拢上去。
所以如果一个东西描述的是能力(Comparable、Runnable 这种,名词后面都能加个 able 的),用接口。如果是一组类确实共享字段和模板逻辑(比如 AbstractList),用抽象类。
Java 8 给接口加了 default 方法,让接口也能带默认实现,算是缓解了"接口一旦发布就没法改"的问题。但注意它没让接口变成抽象类,接口依然没有实例字段,这是本质区别。
异常:受检与非受检的分界线
异常体系的结构很清楚:Error 是 JVM 级的致命问题,别去管也别去捕;Exception 下面分两支,RuntimeException 及其子类是非受检异常,其他的是受检异常。
这个设计的意图是:受检异常针对的是"可预期、可恢复"的情况,比如网络断了、文件不存在,编译器强制你处理它。非受检异常针对的是编程错误,比如空指针、数组越界,这种问题不该 try-catch 兜住,该修代码。
实操里有几条我一直遵守的规矩:
捕获了异常什么都不做(空的 catch 块)是原罪,出了问题排查的人会疯掉。不要用异常控制业务流程,抛异常要生成栈快照,比 if 判断贵得多。另外别在 finally 里写 return,它会吞掉 try 里抛出的异常。
泛型只在编译期活着
Java 的泛型是假的——运行期类型参数被擦除了,List<String> 和 List<Integer> 在运行期是同一个类。
这个设计是历史包袱:泛型是 Java 5 才加的,为了让老代码(没有泛型的)和新代码能互操作,选择了擦除实现。代价就是一堆限制:不能 new T(),不能 new T[],不能 instanceof List<String>。
日常最实用的是通配符,记一条 PECS 原则就够:数据从哪来(你只读它),用 ? extends;数据往哪去(你只写它),用 ? super。比如 copy(List<? extends Number> src, List<? super Integer> dst),读的时候当 Number 用,写的时候可以放 Integer 进去。
HashMap 的几个关键细节
JDK 8 之后 HashMap 的结构是数组加链表,链表长了(到 8 个,且数组长度到 64)会转成红黑树,把最坏查询从 O(n) 拉回到 O(log n)。
几个容易忽略的点:
容量永远是 2 的幂,这样定位桶的下标可以用 (n-1) & hash 这个位运算代替取模,快很多。扩容发生在元素数量超过"容量乘以负载因子"的时候,默认 16 × 0.75 = 12。负载因子 0.75 是空间和时间的一个折中,别乱改。
还有个日常会遇到的:遍历集合的时候直接改它,会抛 ConcurrentModificationException。这不是 bug,是 fail-fast 机制,宁可出错也不让你在脏数据上继续跑。单线程想边遍历边删,用迭代器自己的 remove;多线程场景直接上 ConcurrentHashMap。
并发那三件事:可见性、原子性、有序性
多线程出问题,归根结底就是这三件事:
可见性,一个线程改了变量,另一个线程可能读到旧值,因为大家用的是各自 CPU 核心的缓存。原子性,i++ 看着是一步,实际是读、加、写三步,中间会被打断。有序性,编译器和 CPU 为了性能会重排指令,单线程下重排不影响结果,多线程下会出鬼。
对应的工具箱:volatile 解决可见性和有序性(它会在读写处插内存屏障),但注意它不解决原子性,volatile int i 还是不能安全地 i++。synchronized 三件事全包,但代价也最大。Atomic 系列用 CAS 拿到无锁的原子性,竞争不激烈时性能最好。
理论根基是 JMM 的 happens-before 规则,简单说:如果 A 操作 happens-before B 操作,A 的结果对 B 可见。写并发代码拿不准的时候,就想清楚你依赖的是哪条 happens-before 关系,想不清楚就加锁。
最后
这些东西单独看都不难,难的是知道每个设计背后的权衡。String 不可变是为了常量池和安全,泛型擦除是为了兼容旧版本,HashMap 树化是为了对抗哈希碰撞。理解了"为什么",比记住"是什么"有价值得多——毕竟面试题会变,线上问题不会按八股文出。
- 点赞
- 收藏
- 关注作者
评论(0)