ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java基础第二遍:从语法到设计,源码级补全你的编程思维

Java基础第二遍:从语法到设计,源码级补全你的编程思维 1. 为什么Java基础要学第二遍——第一遍学习留下的思维漏洞我带过不少新人也帮很多读者改过简历和代码。一个特别普遍的现象是很多人说自己“学过Java基础”但真到了写项目或者面试的时候脑子里的知识是散的。比如能默写for循环能背出八种基本数据类型但让他说说“为什么这里要用接口而不是抽象类”或者“ArrayList和LinkedList到底该选谁”就支支吾吾了。这不是态度问题而是第一遍学基础时的天然缺陷把Java当成了一门“语法课”在学而不是当成一门“工具课”在学。第一遍的时候我们的目标是“看懂代码、能跑起来”所以关注点是关键字、语法格式、输出结果。这没错但到了第二遍目标必须升级为“理解设计、懂得取舍、能解释现象”。比如第一遍你学会了String s abc第二遍你要搞明白String为什么是不可变的字符串拼接为什么慢intern()到底做了什么。第一遍你知道HashMap能存键值对第二遍你要能说出它什么时候扩容、为什么负载因子是0.75、什么时候会转成红黑树。第一遍你见过try-catch第二遍你要能判断哪些异常该捕获、哪些异常该抛出、哪些异常你根本不该碰。这篇“Java基础二”本质上就是补第一遍留下的那些思维漏洞。我按自己带新人时最常被问到的、也是实际开发中最容易翻车的几个方向展开面向对象设计、集合源码、字符串、异常处理、反射与动态代理、并发基础。每一块我都会讲“为什么”而不只是“是什么”同时附上真实项目里踩过的坑。如果你正准备Java面试或者已经工作一两年但觉得基础不稳这篇文章应该能帮你把那些零散的点重新串成一条线。2. 面向对象不只是语法从“会写”到“会设计”第一遍学OOPObject-Oriented Programming面向对象编程大家的注意力基本都在“封装、继承、多态”这三个词的定义上。笔试能答但代码里用不好。我经常跟新人说的一句话是OOP不是语法规则是写代码时的决策方式。2.1 封装的核心不是private是保护内部状态很多人觉得封装就是“属性私有化提供getter/setter”。这话只对了一半。封装真正要解决的问题是外部代码不能随意破坏对象内部的合法状态。举个例子你写一个BankAccount类public class BankAccount { private double balance; public void withdraw(double amount) { if (amount 0) { throw new IllegalArgumentException(取款金额必须大于0); } if (amount balance) { throw new IllegalStateException(余额不足); } balance - amount; } }这里withdraw方法内部做了两重校验金额合法性、余额充足性。如果把balance直接设为public外部代码account.balance - 5000就可以绕过所有规则把账户改成负数。封装的价值就在于此它把“对象状态的合法性”控制在了类的内部不暴露给外部。我见过不少刚工作不久的同事写实体类时无脑生成getter/setter甚至连集合都直接return this.list。这就有问题了外部拿到list之后可以随意add/remove完全绕过了类的约束。正确的做法是返回一个只读视图或者用Collections.unmodifiableList包一层。这些都是“第二遍”才应该掌握的细节。2.2 继承和组合不是所有“is-a”都该用继承继承是第一遍OOP讲得最多的但实际工程里组合Composition远比继承常用。经典的反例是“正方形继承长方形”class Rectangle { protected int width; protected int height; // 设置宽、高面积 width * height } class Square extends Rectangle { // 强制 width height重写 setWidth / setHeight }这个设计看起来很合理——“正方形是一个长方形”嘛。但一旦长方形有setWidth(5)后再setHeight(10)的方法正方形的宽高约束就会被破坏。在代码里强行维护这个约束需要各种重写和补丁越补越丑。更好的做法是组合class Square { private final Rectangle rectangle; public Square(int side) { this.rectangle new Rectangle(side, side); } // 对外只暴露 setSide()内部通过 rectangle 实现 }我在实际项目里遇到过一个非常典型的案例业务上有个“订单”和“订单明细”新同事写了个OrderDetail extends Order理由是“明细也是订单的一种”。结果订单的状态字段、客户字段在明细类里全变成继承下来的根本没法用。最后重构花了很大功夫。所以第二遍学面向对象要建立两个判断习惯继承只用在真正的“is-a”关系上并且父类是稳定的抽象。优先使用组合组合更灵活耦合更低。2.3 多态的实际意义面向接口编程多态第一遍讲的是“父类引用指向子类对象”。但工程上更常用的是接口多态。比如你定义一个支付接口public interface Payment { void pay(BigDecimal amount); }然后有Alipay、WechatPay、BankCardPay各自实现。业务代码里依赖的是Payment接口而不是具体实现类。将来要加一种新的支付方式只需要新增一个实现类调用方代码一行都不用改。这就是“开闭原则”的落地对扩展开放、对修改关闭。我当时带的一个项目里最开始写死了Alipay alipay new Alipay()后来要接微信支付改了一堆业务逻辑。重构以后全部换成Payment接口新增支付方式时只需要在配置工厂里注册业务层完全无感。多态不是炫技是降低系统耦合的手段。3. 集合框架源码级别的理解扩容背后藏着性能真相集合是Java开发中使用频率最高的类库之一但第一遍基本只学了个API用法。第二遍必须往源码层面走一层至少把ArrayList和HashMap看透因为这两兄弟在面试和实际性能排查里都躲不掉。3.1 ArrayList扩容为什么频繁add会卡ArrayList的底层是一个Object[]数组。当你调用add()时如果数组容量不够就会触发grow()方法。JDK 8之后的扩容逻辑是int newCapacity oldCapacity (oldCapacity 1);也就是每次扩容到原来的1.5倍。扩容时会调用Arrays.copyOf底层是System.arraycopy把旧数组里的元素整体搬到新数组。这个操作是O(n)的如果在一个循环里往ArrayList里加几万条数据就会反复触发扩容和数组拷贝性能肉眼可见地变差。更关键的是如果用无参构造创建ArrayList初始容量是0第一次add时才扩容到10。所以如果你提前知道大概会存入多少条数据最好直接指定初始容量ListString list new ArrayList(expectedSize);这不是什么高级优化而是日常开发里最简单、最有效的一个习惯。3.2 HashMap为什么默认容量是16负载因子是0.75HashMap的底层结构是数组 链表 红黑树。第一遍只需要知道“键值对存储”第二遍你要能画出它的数据流转过程调用put(key, value)时先对key.hashCode()做一次扰动然后(n - 1) hash算出数组下标。如果该下标位置是空的直接放进去。如果不是空的遍历链表找有没有相同的key有则覆盖没有则尾插JDK 8以后改成尾插。链表长度超过8并且数组长度达到64时链表转成红黑树。当size threshold时触发扩容threshold 容量 × 负载因子。默认容量16、负载因子0.75所以Map里的元素超过12个时就会扩容。为什么负载因子是0.75这是一个时间和空间的折中。负载因子太高比如1空间利用率上去了但哈希冲突也会变多查询效率下降负载因子太低比如0.5冲突少了但浪费空间频繁扩容更伤性能。0.75是实验数据里综合表现比较平衡的一个值。如果你知道要存很多数据务必在new HashMap时传初始容量否则它要反复扩容每一次扩容都要重新计算所有key的下标性能损耗非常大。比如要存1000条数据直接new HashMap(1024)顺便还能手动指定负载因子。3.3 集合选择不要只看时间复杂度还要看场景我见过很多“背书式选型”的答案查询多选ArrayList中间插入多选LinkedList。但真实场景里很少只做中间插入因为LinkedList的节点散落在内存各处对CPU缓存不友好。实际开发中我绝大多数场景直接用ArrayList即使是遍历时做删除也会用迭代器的remove方法或者JDK 8的removeIf。再提醒一个很隐蔽的坑用foreach遍历集合时不能直接add/remove元素。平时写起来没问题但要是有人在多线程环境里这么干就会出现ConcurrentModificationException。原因很简单foreach语法糖背后用的是Iterator它有个modCount校验机制集合被修改后modCount变了迭代器就立即抛异常。这个机制相当于fail-fast快速失败是为了避免在遍历过程中结构被改掉导致不可预期的行为。4. 字符串、StringBuilder与不可变性最容易被忽略的“基础”字符串是Java里最常用的对象没有之一。但第一遍很少人会去想String为什么设计成不可变的这其实是一个价值极高的问题。4.1 不可变的好处不可变带来的好处至少有四个线程安全多个线程同时读同一个String对象不需要加锁。哈希缓存String的hashCode可以安全地缓存起来所以它特别适合做HashMap的key。常量池复用因为对象内容不会变JVM才敢把相同内容的字符串在常量池里共享。安全类加载、网络参数、文件路径这些场景都是字符串如果是可变的被篡改后果不堪设想。你想想如果String是可变的那么HashMap里作为key的字符串哪天被改掉了内容hashCode就不一致了可能出现“存进去找不到、取出来不是同一个对象”的诡异问题。4.2 字符串常量池与intern看一段代码String s1 hello; String s2 hello; String s3 new String(hello); System.out.println(s1 s2); // true System.out.println(s1 s3); // falses1 s2为true是因为两个字面量指向常量池中同一个对象。s3用new创建强制在堆上生成新对象所以比较为false。如果用s3.intern()会去常量池里找找到就返回常量池对象这样s1 s3.intern()就为true了。但我要多说一句日常开发中不要滥用intern()。如果常量池里的字符串非常多它会占用永久代JDK 7以前或元空间JDK 8以后之外的一块区域字符串常量池在堆上。曾经有线上故障就是因为有人用大量动态字符串调intern()把堆内存直接撑爆了。正常人写业务代码不需要关心字符串引用相等用equals()判断内容就好了。4.3 StringBuilder vs StringBuffer vs 字符串拼接很多教程里说“字符串拼接慢要用StringBuilder”但没有讲清楚为什么。因为String是不可变的每次用拼接字符串实际上都会生成新的String对象。比如str str a b c在循环里会反复创建中间对象GC压力巨大。而StringBuilder内部是一个可变的char数组append()只是在数组里追加内容最后toString()一次性生成结果。JDK 5以来javac其实会把简单的字符串优化成StringBuilder的append调用。但在循环里拼接仍然会有问题因为每次循环都会new一个新的StringBuilder。所以循环拼接的代码一定要手动声明StringBuilderStringBuilder sb new StringBuilder(); for (int i 0; i 100000; i) { sb.append(i); } String result sb.toString();StringBuffer和StringBuilder的区别只有一个StringBuffer的方法加了synchronized是线程安全的StringBuilder没加线程不安全。在单线程环境下用StringBuilder就够了用StringBuffer纯属白白多上锁性能会差一些。这里有个度量如果只有一个线程访问StringBuilder比StringBuffer快5%到15%左右数据量越大差距越明显。5. 异常处理与“源发行版17”这类报错的排查思路异常处理是Java基础里非常“实用”的一块但也是最不被重视的一块。第一遍很多人就是写个try-catch把错误打印一下然后不管了。第二遍要搞清楚异常的分类、抛出时机以及碰到编译期报错时怎么一步步找到根因。5.1 数组越界异常的本质热搜词里有“java中数组越界异常”这确实是一个高频问题。ArrayIndexOutOfBoundsException本质上就是你访问的下标不在[0, length-1]这个区间内。它的触发场景很直白比如int[] arr new int[5]; System.out.println(arr[5]); // 数组下标5无效最大下标是4这个异常为什么会高频出现大多数情况是循环边界写错了。比如从1开始循环到数组长度或者i arr.length都会越过最后一个合法下标。排查这类问题有个很实用的方法在报错信息里看是哪个下标越界再回代码里看这个下标是怎么算出来的。如果下标是动态算的就要考虑边界值。还有一个很隐蔽的坑先访问数组再判断长度。比如先取arr[0]再判断arr.length 0如果数组是空的一样会越界。正确的顺序是先判空、再判长度、最后才访问元素。5.2 受检异常与非受检异常的取舍Java把异常分成两类受检异常Checked Exception编译器强制要求你处理比如IOException、SQLException。不处理就编译不过。非受检异常RuntimeException编译器不强制要求比如NullPointerException、ArrayIndexOutOfBoundsException、IllegalArgumentException。很多人第一遍学的时候有个误解受检异常是好事强制你处理错误。但实际工程里过度使用受检异常会让代码变得很啰嗦到处都是try-catch和向上抛的签名。我认为的正确原则是对于调用方能自行恢复的情况用受检异常或者返回错误码。对于程序bug空指针、下标越界、参数不合法用非受检异常让它尽快暴露而不是用catch吞掉。catch到异常后绝对不能只打印日志就完事至少要思考这个异常是不是可以被忽略的。我见过最离谱的代码是catch了异常然后什么都不写问就是“没事不会走到这里”。等真走到那里的时候线上排查半天日志一片空白完全不知道发生了什么。任何catch后沉默的行为都是在给未来的自己埋雷。5.3 “警告: 源发行版 17 需要目标发行版 17”的排查逻辑热搜词里有一个非常典型的编译问题“java: 警告: 源发行版 17 需要目标发行版 17”。这个报错在IDEA里非常常见本质上是编译器的源版本和目标版本设置不一致。源发行版source决定了编译器接受什么版本的Java语法目标发行版target决定了生成的字节码运行在什么版本的JVM上。当source设为17target却是更低版本时编译器就会提示“源发行版17需要目标发行版17”。排查步骤检查File - Project Structure - Project里的SDK和Language level是不是都指向17。检查Settings - Build, Execution, Deployment - Compiler - Java Compiler里的Target bytecode version是不是17。检查pom.xml或build.gradle里的maven.compiler.source和maven.compiler.target是不是也设置了17。最容易被忽略的IDEA偶尔会缓存旧的编译配置改了配置后报错还在那就Build - Rebuild Project或者File - Invalidate Caches / Restart。我之前遇到过一次项目SDK是17但IDEA里有个模块覆盖了全局设置单独把language level指到了11。折腾了很久才在模块设置里找到那个覆盖项。遇到编译版本问题先看有没有“模块级配置覆盖项目级配置”这是IDEA里最常见的坑。类似的还有环境变量JAVA_HOME指向了8而IDEA里JDK选的是17两者不一致同样会编译异常。6. 反射与动态代理框架魔术的底层骨架很多人学Java基础时觉得反射和动态代理“很抽象、用不上”。但真相是Spring、MyBatis、Hibernate这类框架全靠反射和动态代理才能跑起来。你如果看不懂这两个东西就永远只能停留在“会用框架”的阶段框架一出问题就抓瞎。6.1 反射运行期的“开箱检查”反射的本质是在运行期间获取类的完整结构信息并且能动态调用方法、访问字段。第一遍时学的写法往往是Class? clazz Class.forName(com.example.User); Object obj clazz.getDeclaredConstructor().newInstance(); Method method clazz.getMethod(setName, String.class); method.invoke(obj, 张三);看起来是不是比直接new User()麻烦多了但反射解决的是“编译期不知道要操作哪个类”的问题。比如Spring容器根据XML或注解里的类名在运行期动态创建Bean它不可能在编译期写死new UserService()因为要处理的是任意未知类型。这里有一个重点getMethod只能获取public方法包括继承来的getDeclaredMethod能获取当前类自己声明的所有方法包括private的。要调用private方法需要先setAccessible(true)。setAccessible其实就是绕过Java的访问控制检查在框架代码里非常常见。6.2 动态代理InvocationHandler到底在干嘛动态代理是Java面试里绕不开的点核心接口就是InvocationHandler。我自己是在看Spring AOP源码时才真正理解它动态代理是在运行期生成一个代理类所有方法调用先经过InvocationHandler再由它决定怎么处理。最典型的写法public class LogHandler implements InvocationHandler { private final Object target; public LogHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println(调用前: method.getName()); Object result method.invoke(target, args); System.out.println(调用后: method.getName()); return result; } }使用时UserService service new UserServiceImpl(); UserService proxy (UserService) Proxy.newProxyInstance( service.getClass().getClassLoader(), service.getClass().getInterfaces(), new LogHandler(service) ); proxy.findUser();这样就能在不修改原始类代码的情况下给findUser()方法增加日志功能。这就是AOP面向切面编程的底层实现方式之一。Spring的Transactional能在方法前后自动开启/提交/回滚事务本质上也依赖这个机制。有一个很重要的前提JDK动态代理只能代理接口不能代理类。如果目标类没有实现任何接口就得借助CGLIB通过生成目标类的子类来代理。所以Spring在选择代理方式时有个逻辑有接口就用JDK动态代理没有接口就走CGLIB。这个知识点面试里几乎必问。6.3 反射和动态代理的实际坑实际使用反射最大的坑是性能。反射调用比直接调用慢很多虽然现代JVM有缓存的优化手段但频繁反射仍然会有额外开销。所以框架层的反射通常会有缓存比如Spring会把解析出来的Method对象缓存起来避免每次都getMethod。我们在业务代码里也应该遵循这个思路如果一定要用反射至少把Class、Method、Field缓存好。另一个坑是反射能破坏封装。setAccessible(true)以后即使是private字段也能被改写。很多人觉得这违反OOP但从框架的角度看它恰恰是“灵活”的代价。作为开发者我们应该清楚反射是给框架使用的不是给业务代码乱用的。业务上能用直接调用解决的就不该上反射。7. 并发基础从线程安全到AQS与数据一致性并发是Java基础里最“进阶”的部分但也是现代后端开发的必备技能。第一遍学Java时可能只接触了Thread和Runnable第二遍至少要理解线程不安全到底是怎么产生的、synchronized和volatile有什么区别、AQS在Java并发包里处于什么位置。7.1 线程安全问题的源头多个线程同时访问同一个变量就会出现竞态条件。最经典的例子是iprivate int count 0; public void increment() { count; }count看起来是一条语句但在字节码层面是三步读取count、加1、写回count。线程A和线程B同时执行时可能出现A读到count5B读到count5A计算得到6写回B也计算得到6写回结果count6但正确结果应该是7这就是丢失更新。解决方式有很多但基础里的核心是synchronized。它保证了互斥同一时刻只有一个线程能进入同步块。7.2 synchronized和volatile别搞混synchronized解决的是“原子性可见性”问题。进入同步块时会获取锁退出时会释放锁并且把本线程的修改刷新到主内存。volatile解决的只是“可见性”问题。它保证一个线程修改了变量其他线程能立即看到最新值但它不保证原子性。所以volatile适合修饰状态标志位比如volatile boolean running true;一个线程在循环里检查running另一个线程把它置为false来停止循环。这种情况用volatile就够了不需要synchronized。但如果是对同一个变量做count这种复合操作volatile救不了必须加锁或者用原子类AtomicInteger。我见过有人把synchronized加在方法上其实会把整个方法变成一个串行区并发能力急剧下降。正确的做法是尽量缩小锁的范围只锁需要保护的临界区代码。比如public void addOrder(Order order) { // 不需要加锁的前置校验 valid(order); // 只有操作共享资源的部分需要锁 synchronized (this) { orderList.add(order); } }锁的范围越小阻塞的时间越短系统吞吐量越高。这个优化思路在实际项目中非常重要。7.3 AQS到底是个什么东西AQS全称是AbstractQueuedSynchronizer中文基本叫“抽象队列同步器”。它是很多并发工具类的底层基础比如ReentrantLock、Semaphore、CountDownLatch都建立在它之上。一句话理解AQS它维护了一个volatile的state变量和一个等待队列。当你尝试获取锁时就是CASCompare-And-Swap比较并交换尝试把state从0改成1成功了就拿到锁失败了就进入等待队列排队。释放锁就是把state改回0再唤醒队列头部的线程。面试里问AQS主要考两个点state的意义对于ReentrantLockstate表示锁的重入次数对于Semaphorestate表示剩余的许可数对于CountDownLatchstate表示还需要等待多少个事件。CLH队列的变种AQS里的等待队列是一个双向链表每个节点里面保存着等待的线程。当前一个节点释放锁时会唤醒后一个节点。我在看AQS源码之前总觉得并发工具很神秘。看完之后发现它们的核心就三两行CAS操作其余全是围绕state和队列做的状态管理。基础不牢的人看并发源码会觉得各种回调绕来绕去其实本质都是同一个套路CAS改状态失败了排队释放了唤醒。7.4 Java层面的“数据一致性”意味着什么热搜词里有“java怎么保证数据一致性”。这里要区分两个层面一个是并发编程里的内存一致性一个是分布式事务里的数据一致性。后者通常要靠数据库事务、消息队列、分布式事务框架解决不是Java语法本身能搞定的。但Java基础层面能做的是保证单机多线程环境下对共享数据操作的原子性、可见性和有序性。Java内存模型JMM定义了主内存和工作内存之间的交互规则所有变量都存在主内存中。每个线程有自己的工作内存里面保存变量的副本。线程对变量的操作必须先在副本上进行然后再同步回主内存。这个模型决定了“为什么会有可见性问题”线程A改了副本还没来得及写回主内存线程B还读着旧副本。volatile、synchronized、final这些关键字本质上都是围绕JMM做约束保证跨线程的可见性。用一句话总结Java并发基础学的是“多线程协作时的纪律”。没有纪律数据就乱了有了纪律同步机制多线程才能有序协作。8. 学完这些之后把基础二转化为项目能力看完上面六个方向你会发现“Java基础二”其实不是一个独立的知识点而是把第一遍零散的语法知识按“为什么”重新串了一遍。这是我个人带新人时最推荐的第二遍学习路径不要只是看视频要带着问题去读源码、做实验。一个很实用的练习方法每天选一个常用类从源码入手看它的设计思路。比如下周每天看一个集合类从ArrayList开始到LinkedList、HashMap、ConcurrentHashMap。不用全部看懂重点看构造方法、扩容逻辑和线程安全处理。对自己写过的代码做一次“为什么”审查。每个类为什么这样设计每个方法为什么这样写如果换一种写法会有什么问题找不到问题说明还没思考到位。把报错信息当成学习资源。每次遇到编译错误、运行时异常不要只在搜索引擎里搜答案先自己分析“报错信息告诉了我什么”再顺着线索深挖直到找到本质原因。我之前用这个方法处理了很多IDEA编译版本不一致的问题后来基本扫一眼报错就能定位。最后再分享一个我觉得特别重要的心态基础类的知识从来都不是“学一遍就够”的。我自己工作七八年了回头看HashMap源码和AQS源码每次都会有新的理解。第一遍学个大概第二遍搞懂原理第三遍在生产环境遇到问题后再回来对照每次的感受都不一样。不要急着追新框架Java基础这栋楼的地基打得稳上面盖什么框架都稳。如果你能把上面这些“为什么”都能对着源码说清楚再去看面试题、再去接手项目你会明显感觉心里踏实很多。
返回列表