ARTICLE DETAIL

资讯详情

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

JavaSE复习指南:从语法基础到JVM的核心知识地图

JavaSE复习指南:从语法基础到JVM的核心知识地图 1. JavaSE知识复习先画出属于你的技术地图JavaSE这个名称大学课本里出现过无数次但真到了找工作、写项目、回炉复习的时候大家才发现它根本不是一门“随便背背就能过”的课。很多人在刷题阶段总会遇到一种奇怪的感觉知识点都见过Collection接口能背出来HashMap的原理也能说两句但做项目时一碰到并发修改、内存溢出、泛型擦除问题还是懵。JavaSE是Java体系的底座。无论你后面做Spring Boot后端、Android开发还是大数据方向绕不开的底层逻辑全在这里面向对象是代码组织的思路集合框架是处理数据的工具多线程是提升系统吞吐量的手段JVM则是理解线上故障的最终依据。这篇东西就是给正在系统复习JavaSE、或者准备从“会写”走向“理解”的你准备的。我不打算把整本《Java核心技术》堆在这里而是按自己这些年带新人、做项目、复盘面试题的经验把JavaSE拆成一张复习地图再挑最容易踩坑的部分深入展开。你拿来就能用关键是能真正搞懂每个知识点背后的“为什么”。先说复习思路上的一个常见误区很多人一提到复习JavaSE就是打开网上的面试题合集背集合区别、背线程状态、背垃圾回收算法。这样短时间确实能应付一些提问但一旦被追问“为什么用红黑树”“为什么负载因子是0.75”“为什么局部内部类访问局部变量要final”马上露馅。真正的JavaSE复习应该是一条追问链从使用到原理从这个知识点到关联知识点先画图再逐块击破。我建议按照下面的框架来组织复习内容后文也基本沿这条线走复习模块核心内容常见误区语法基础关键字、运算符、String、包装类只背结论不记底层缓存与比较规则面向对象封装、继承、多态、抽象类与接口把“语法”当成“设计”忽略原则讲究集合框架List/Set/Map、源码结构与扩容规则会调用API不理解时间和空间的取舍异常与泛型异常体系、处理原则、泛型上下界只try-catch不明白异常链传递与泛型擦除新特性Lambda、Stream、Optional不会把函数式思想应用到实际编码IO与并发字节流、字符流、线程、锁、线程池线程只会new不理解生命周期与锁的本质JVM内存区域、类加载、垃圾回收参数背得熟不懂堆/栈问题的定位逻辑这个框架看起来简单但每一块都能深挖。复习时最好准备一个笔记本不抄概念只记录“原理场景踩坑事例”。我自己带新人时经常说能把一个点用大白话给旁边同事讲明白这个点才算真正吃了。2. 语法基础盘点最容易阴沟翻船的比较与关键字JavaSE复习第一步永远是从语法开始。不过基础不代表着简单很多工作了三五年的开发者照样会在几个基础问题上翻车。这里挑几个我切实遇到过的坑位详细展开。2.1 Integer缓存问题别再用比较包装类先看一段很经典的问题代码Integer a 127; Integer b 127; System.out.println(a b); // true Integer c 128; Integer d 128; System.out.println(c d); // false如果对Java没有深入了解第一反应会认为四个比较结果应该一致。但运行之后会发现127那组是true128那组是false。原因是Integer内部有一个缓存数组默认缓存了-128到127范围内的Integer对象。使用字面量赋值时编译器会调用Integer.valueOf()方法而valueOf()内部会优先从缓存里返回对象。127在缓存范围内所以a和b指向同一个对象128超出范围每次都会new一个对象比较自然就是false。复习到这里就要再追问一个“为什么”为什么偏偏是-128到127这个范围因为这个区间是日常使用频率最高的小整数JVM希望通过对象复用减少内存开销。这个范围其实可以通过JVM启动参数调整只是绝大多数场景直接用默认值就够了。日常编码中用比较包装类是高风险操作。如果需要比较数值大小一律用equals()或直接拆成基本类型。这个习惯一旦养成能规避掉大量线上隐藏bug。2.2 final、static、abstract这些关键字不是背定义那么简单很多基础复习资料只是列了关键字的作用但实际上关键字背后是JVM的绑定机制面试官最爱的追问也在这里。final修饰变量表示值不能被修改。但“值”这个概念要分情况修饰基本类型时值本身不能变修饰引用类型时引用地址不能变但对象内部属性可以变。所以下面这段代码是合法的final ListString list new ArrayList(); list.add(hello); // 合法list指向的对象没变static修饰成员表示这个成员属于类而不属于实例。加载时机是类加载阶段所以static方法的调用不依赖对象实例。这里有个细节值得复习static方法不能直接访问非static成员因为非static成员需要先有对象才能访问而static代码块执行时对象可能还不存在。abstract和final是对立的两个方向一个表示“必须被继承/重写”另一个表示“不能继承/重写”。所以一个类不能同时被abstract和final修饰这属于编译期就能发现的语法错误。理解这些关键字时千万别孤立记忆可以把它们放在“编译期限制”和“运行期行为”两个维度上串联。2.3 String、StringBuilder、StringBuffer到底怎么选这三个类在JavaSE复习里是老面孔但真正理解透彻的人不多。String是不可变类内部用final char数组或者JDK9之后的byte数组存储一旦创建就不能修改。任何对String的修改操作都会生成新对象。所以循环中拼接大量的String会产生大量中间对象导致GC压力变大。StringBuffer是线程安全的可变字符序列关键方法都加了synchronized。多线程环境下拼接字符串可以用它。不过大多数业务场景并不真的存在多线程同时操作同一个StringBuffer这种情况下用StringBuffer反而白白付出了加锁和竞争的开销。StringBuilder是JDK5引入的行为上和StringBuffer基本一致只是没有同步修饰。单线程下的字符串拼接性能最好。因此没有明确的线程安全诉求时优先选择StringBuilder。顺便复习一个和String相关的常量池问题。直接使用双引号声明的字符串JVM会去字符串常量池中查找通过new String()创建的对象则先在堆上创建对象再决定是否在常量池中保留引用。理解这一块时不妨画一张字符串对象在堆和常量池的分布图很多关于intern()的面试题都是从这衍生出来的。我在实际复习时发现语法基础这块最容易犯的错误是“自以为是”。单词都认识题目一做就错。建议找几套带详细解析的基础题集把每一个错题考点都还原成一条底层机制而不是只背答案。3. 面向对象的思想与设计约束比语法更值得死磕如果说语法是JavaSE的肌肉面向对象就是骨架。很多人能默写出封装、继承、多态的定义但代码里全是面向过程的味道。3.1 封装不是私有化是控制边界初学Java时封装的定义通常被简化为“属性私有化提供getter/setter访问”。如果只在复习层面理解到这个深度后端代码就很容易变成纯数据模型一个个傻对象毫无行为到处都是get和set的业务代码这其实是反封装。真正的封装是把数据和对数据的操作绑定在一起对外只暴露最小可用接口隐藏实现细节。比如一个订单类不应该任由外部把状态字段直接改成任意值。更好的做法是把状态流转的方法暴露出去内部校验状态转换是否合法。复习时可以用设计原则的视角去看封装是在控制可变性的边界降低模块间的耦合度。权限修饰符的选择也体现封装的理念。public暴露给外部调用者protected留给子类扩展private隐藏内部细节。默认的包级私有访问权限其实在编写同包协作的工具类时很实用只是很多开发者习惯了“要么public要么private”完全忽略了包级私有这个中间态。3.2 继承与重写要搞懂JVM的绑定规则继承在Java中用extends关键字实现但面试官一定会追问子类创建对象时构造器的调用顺序是什么这时候答案应该是先执行父类的静态代码块再执行子类的静态代码块然后执行父类普通代码块和构造器最后执行子类普通代码块和构造器。静态部分只会在类加载时执行一次。重写需要满足一个容易被忽视的约束子类方法的访问权限不能低于父类方法返回值类型可以相同或是父类返回类型的子类型协变返回类型。为什么访问权限不能降低因为继承表达的是一种“is-a”关系子类需要能在任何父类出现的地方替代父类一旦子类把方法权限收紧了外部通过父类引用调方法就可能失败。多态的存在依赖三个条件继承、重写、父类引用指向子类对象。调用方法时JVM会根据对象实际类型动态绑定去找最匹配的方法版本。这里有一个经典面试问题属性成员变量有没有多态答案是没有。成员变量在编译期就已绑定访问时看引用类型。举例来说class Animal { String name animal; } class Dog extends Animal { String name dog; } Animal a new Dog(); System.out.println(a.name); // 输出 animal因为没有“属性多态”这种坑在代码里一旦出现很难快速定位因为编译期不会报错逻辑上还特别容易误判。3.3 抽象类与接口Java8之后界限更清晰早期Java版本里抽象类和接口的边界很分明。抽象类可以有构造器、成员变量、具体方法接口只有常量和抽象方法通过implements实现时必须全部实现抽象方法。Java8给接口增加了默认方法和静态方法Java9又允许定义私有方法两者的界限开始模糊。但这不意味着两者可以随意互换。我的经验是抽象类更适合描述“同一类事物的模板”比如多个动物都有吃和睡的逻辑只是动作细节不同可以把公共字段和公共流程放到抽象类里用抽象方法把差异点交给子类。接口更适合描述“跨越不同类的能力”比如飞行能力鸟会飞、飞机也会飞但它们没有相同的血缘所以用接口更合理。复习时千万别只背“抽象类单继承、接口多实现”这种表面区别。遇到设计问题时要从模板复用和契约能力两个角度去选。如果实在拿不准一个很实用的判断标准是先看有没有共享状态字段有共享状态优先用抽象类纯粹是能力约定就选接口。4. 集合框架从背结构上升到源码级理解Java集合是JavaSE复习中风险最高的模块因为这块内容表面上是API记忆实际上是数据结构和并发思想的混合体。只背面试题的答案面试官稍微深挖底层细节就很容易应对不上。4.1 先从容器接口分级理清整个体系集合框架的主干分为两大接口Collection和Map。Collection下面有List、Set、Queue三大子接口。List有序且允许重复常见实现有ArrayList、LinkedList、Vector。Set不允许重复常见实现有HashSet、LinkedHashSet、TreeSet。Queue是队列接口在并发场景和任务调度场景用得较多。Queue这个分支经常被复习者忽略但DelayQueue、ArrayBlockingQueue等实现其实在后端开发中很常用。Map自成体系保存键值映射。常见实现有HashMap、LinkedHashMap、TreeMap、ConcurrentHashMap。需要注意Map并不继承Collection接口虽然它们同属集合框架。面试题里“HashMap和HashSet有什么关系”这个经典问题本质是在考察源码阅读能力。4.2 ArrayList与LinkedList的选择不只是“数组和链表的区别”大部分人能说出ArrayList底层是动态数组LinkedList底层是双向链表前者查询快、后者增删快。但到了真实业务场景这个粗糙结论很可能被推翻。ArrayList基于数组实现支持随机访问get操作的时间复杂度是O(1)LinkedList链表结构访问某个下标的元素需要从头或尾遍历时间复杂度是O(n)。而ArrayList的增删之所以看起来慢是因为中间插入或删除需要移动后续元素时间复杂度O(n)LinkedList的中间插入删除确实只需要断开和重连指针但找到这个节点位置本身需要遍历复杂度的常数不小。更重要的是现代CPU和内存体系下数组的连续内存访问对缓存更友好ArrayList在实际运行中往往表现更好。所以项目中无脑用ArrayList问题不大LinkedList更适合大量头尾操作以及需要频繁在中间位置插入且已持有节点引用等特殊场景。ArrayList扩容也是一个经典考题。初始容量为10每次扩容扩大为原来的1.5倍也就是newCapacity oldCapacity (oldCapacity 1)。为什么要1.5倍而不是两倍扩容会进行数组复制代价不小1.5倍是一种空间增长和复制频率的折中。如果预先知道要存放多少数据一定要使用指定容量的构造器这是很实用的性能优化手段。4.3 HashMap这个老演员值得把源码从头读一遍HashMap在Java面试里出现的频率可能是所有集合类中最高的。复习时应关注几个层次。第一是hash定址规则。HashMap底层是数组加链表加红黑树JDK8之后。put时先对key计算hash再用“扰动函数”让高位参与运算最后通过hash (n-1)定位到数组下标这里的n是数组长度所以容量要求是2的幂次方因为2的幂减一后的二进制全是1运算才能均匀散列。第二是负载因子。默认负载因子是0.75f。为什么是0.75这是一个时间和空间的权衡负载因子过低比如0.5数组很快扩容空间浪费多过高比如1.0链表太长查询效率降低。0.75是开发者长期测试下来的经验平衡值。阈值可以通过cap和loadFactor计算出来当元素个数超过阈值时触发扩容。第三是树化条件。JDK8中的一个链表节点数达到8且数组长度达到64时链表会转成红黑树树节点减少到6时又转回链表。树化是为了防止有人恶意构造hash冲突、拉长链表造成DoS攻击。为什么阈值是8源码注释里给过一个泊松分布的分析数据在随机hash的模型中单个链表节点数达到8的概率极低只有约千万分之六。8和6之间留了2个数的缓冲防止频繁在树和链表之间来回切换。第四是HashMap的线程安全问题。HashMap本身线程不安全。并发put时JDK7可能因为头插法造成环形链表在扩容时形成死循环导致CPU飙高JDK8改为尾插法后环形链表隐患有所缓和但数据覆盖、丢失问题依旧存在所以并发环境必须使用ConcurrentHashMap。复习时如果能从JDK1.7到1.8的演进脉络去理解会显得思路非常清晰。JDK8的ConcurrentHashMap放弃了Segment分段锁改用CAS加synchronized锁定桶的头节点加锁粒度更细。写操作时如果对应桶为空用CAS直接放进去如果桶不为空则对头节点加synchronized。这样不同桶的并发操作互不干扰整体并发性能有了明显提升。集合这块我建议复习时落实到代码动手写几个百万量级数据的测试直观感受不同数据结构和参数的差异。只看文章和只背面经永远建立不了真正的技术手感。5. 异常与泛型新特性这些细节平时没人提醒异常和泛型是JavaSE中偏向“工程化”的部分。它们不会像集合或并发那样直接成为热门笔试题但团队协作、代码审查、线上问题排查时天天都要用到。5.1 异常体系要懂分类更懂处理原则Java的异常体系主干很清晰Throwable下面分Error和Exception。Error表示JVM层面的严重问题比如OutOfMemoryError、StackOverflowError通常不需要程序去捕获。Exception下分受检异常checked exception和运行时异常RuntimeException。受检异常编译期强制处理比如IOException、SQLException。这类异常发生时程序状态往往是外部环境造成的问题比如文件不存在、网络断了。运行时异常则代表程序逻辑缺陷比如NullPointerException、ArrayIndexOutOfBoundsException编译期不强制捕获但这类bug恰恰是线上最常见的问题来源。我见过不少团队处理异常时习惯把try-catch放得特别大直接包住整个方法然后打印一个日志就算完事。这种做法问题很大捕获了异常但没正确处理业务状态中途已经发生了变更结果把错误吞掉后续流程继续跑出一堆脏数据。真正合理的处理原则是能用if判断避免的异常不要用try-catch去兜。比如调用前判断对象是否为null这比捕获空指针异常更干净。如果当前方法没有能力处理这个异常就继续向上抛出让上层统一处理。捕获异常后一定要有兜底逻辑比如关闭资源、回滚事务、记录自定义错误上下文。不要捕获Exception基类来掩盖所有问题否则会让代码的可维护性大打折扣。5.2 资源关闭的正确姿势从try-finally到try-with-resourcesIO、网络连接这类资源用完必须关闭这是JavaSE复习里强调很多的基础。早期写法是InputStream in null; try { in new FileInputStream(test.txt); // ... } finally { if (in ! null) { in.close(); } }如果遇到两个资源要关闭嵌套的finally会非常冗长。JDK7之后引入了try-with-resources只要资源实现了AutoCloseable接口就可以这样写try (InputStream in new FileInputStream(test.txt); OutputStream out new FileOutputStream(out.txt)) { // 自动关闭 } catch (IOException e) { log.error(copy fail, e); }代码简洁程度大幅提升而且JVM会保证无论正常执行还是抛异常资源都能正确关闭关闭顺序与声明顺序相反。复习时很多人只学会了语法没注意到一个底层细节如果try块里抛出了异常而close()也抛出了异常原始异常还能不能被调用方感知Java里是能的try-with-resources会自动把close()的异常作为 suppressed 异常附加到原始异常上。这个问题很刁钻但面试官要是问起能答上来会非常加分。5.3 泛型擦除、上下界和PECS原则泛型在编译期提供类型约束但JVM字节码层面没有泛型概念。ArrayList 和 ArrayList 在运行时是同一个类ArrayList这就是类型擦除。正因如此无法直接通过instanceof判断一个List里装的具体类型也不能new T()这样的操作。类型擦除会引出桥接方法的技巧。如果父类定义了一个返回Object的方法子类重写为String返回类型编译器会生成一个桥接方法保持重写链路的正确性。泛型通配符中有个PECS原则需要重点掌握Producer ExtendsConsumer Super。如果要从集合中读取数据使用extends上界通配符如果要写入数据使用super下界通配符。这个原则和java.util.Collections的copy方法联系紧密是很多人复习时最后一公里没打通的知识点。举一个实际场景public void copy(List? super Integer dest, List? extends Integer src) { for (Integer num : src) { dest.add(num); } }src只读用extends Integer可以安全读取dest只写用super Integer可以安全添加Integer。如果反过来写代码能不能编译通过都成问题。这就是泛型设计里“生产者只能取消费者只能存”的直观体现。5.4 Lambda、Stream和Optional函数式思想的落地Java8新特性是现在中高级岗位面试绕不开的范围。Lambda表达式本质上是函数式接口的匿名实现类的简化写法而函数式接口就是只有一个抽象方法的接口比如Runnable、Comparator、Consumer、Function、Predicate等。配合FunctionalInterface注解可以提醒编译器对这个约束做验证。Stream则是对集合数据进行函数式流水线操作的抽象。复习时要特别注意中间操作和终止操作的区别中间操作是惰性的比如filter、map、sorted不调用终止操作它们根本不会执行终止操作比如collect、forEach、reduce才会触发整条流水线计算。Optional建议在复习时多花点时间。它的出现是为了避免NullPointerException但有相当多的人对它的使用其实是“滥用”看到返回值就.ifPresent包装一遍代码写了一长串可读性反而下降。我的经验是Optional更适用于表示可能缺失的返回值并支持优雅的default值或orElseThrow处理不适合做方法参数类型。方法参数是null还是非null应该由调用方通过javax.annotation.Nullable这类注解或业务文档约定来约束靠Optional强制要求调用方去判断反而更不好用。6. IO与多线程从串行到并行的进阶之路IO流和多线程像是JavaSE里两座独立的高山。IO偏数据传输原理多线程偏系统并发调度。工作中两者经常组合出现比如高并发读文件、网络IO等场景复习时也可以把它们放在相邻位置系统看。6.1 Java IO模型演进从BIO到NIO再到AIO传统的Java IO是阻塞IOBlocking IO以InputStream/OutputStream和Reader/Writer为主体。字节流按字节读写字符流按字符读写底层都涉及编码转换。阻塞IO在等待数据就绪时线程会一直阻塞无法处理其他任务。如果一个服务需要处理大量并发连接再配合一个连接建立一个线程的模型线程数会迅速膨胀资源开销巨大。这就是为什么后来的NIO模型出现了。NIO的核心在于Channel、Buffer、Selector这三大组件。一个Selector可以监听多个Channel上的事件线程不阻塞在具体的IO上而是轮询事件有事件再处理。这样单线程就能管理成千上万个连接。IO多路复用并不是Java独有的概念操作系统层面select/poll/epoll提供了底层支撑Java NIO只是做了封装。AIO异步IO在Java 7中推出真正实现异步非阻塞IO完成后通过回调或Future通知应用程序。但实际落地效果一般主流网络编程框架如Netty的核心仍是NIO模型而非AIO。原因在于操作系统的异步IO机制不统一Java层面的封装也未必能达到理想的高性能。复习这部分时我推荐把BIO、NIO、AIO的概念与生活中的餐馆点菜模型做类比。BIO是一个服务员接待一个客人点菜期间干等着NIO是一个服务员同时监测多桌客人的举手动作有空位马上招呼AIO是客人按铃呼叫服务员听到铃声才过去服务。这样理解阻塞、非阻塞、异步的差异会轻松很多。6.2 线程的创建方式与生命周期创建线程有很多种方式继承Thread类、实现Runnable接口、使用Callable和FutureTask获取返回值、使用ExecutorService线程池提交任务。前两种本质上没有区别都是把任务逻辑交给Thread去执行。需要返回值就用Callable因为Runnable的run方法没有返回值。线程生命周期这里画图很关键。Java线程状态是六种对应Thread.State枚举。理解重点在于start()方法让线程进入就绪状态不代表立即运行需要等待CPU时间片。调用sleep()时不释放锁Thread.sleep进入TIMED_WAITING期间其他线程进不了同步代码块。调用wait()后释放锁必须配合synchronized使用否则报IllegalMonitorStateException。join()是一种线程协作方式A线程调用b.join()时A会阻塞等待B执行完成。通常用interrupt()来协作式中断线程收到中断标记后是否退出取决于代码对中断状态的处理。6.3 synchronized与ReentrantLock锁的根本问题多线程的核心矛盾是共享数据的操作不具备原子性、可见性和有序性。Java提供了synchronized关键字和JUC包里的ReentrantLock来解决。synchronized在JDK6之后做了大量优化引入了偏向锁、轻量级锁、重量级锁的升级过程。锁的状态会随着竞争程度变化并不是一上来就阻塞线程。因此现在很多常规业务场景下synchronized的性能并不差。ReentrantLock是API层面的锁使用更灵活。它提供定时锁、可中断锁、公平锁等synchronized没有的能力。因为是基于AQSAbstractQueuedSynchronizer实现的所以核心逻辑是维护volatile变量state和一个CLH变种队列。每次lockstate加1unlockstate减1直到0才真正释放锁。同一条线程可以重复加锁这就是“可重入”。选择锁时我的基本原则是只需要基础的同步直接选synchronized代码简洁后续维护成本低需要尝试获取锁、限时等待或者需要多个条件队列时选择ReentrantLock。性能上在JDK8之后两者的差距不明显不要听信要么谁一定强过谁的论调。6.4 volatile与可见性以及ThreadLocal的内存泄漏风险volatile解决的是可见性和有序性不是原子性。它强制对变量的写操作立即刷新到主内存读操作从主内存读取同时通过内存屏障限制指令重排。经典的DCL单例场景中单例字段需要volatile修饰原因在于对象创建过程分为分配内存、初始化、赋值三步指令重排可能让另一个线程拿到了未初始化完成的对象。注意volatile并不能解决i这类复合操作的并发安全。ThreadLocal则是一种线程隔离机制。每个Thread内部有一个ThreadLocalMapkey是ThreadLocal对象value是线程私有的数据。使用ThreadLocal时要特别注意内存泄漏ThreadLocalMap中的Entry继承自WeakReferencekey是弱引用如果ThreadLocal外部没有强引用了key会在GC时被回收变成key为null的Entry这个Entry的value却依然是强引用无法被自动清理。因此使用完ThreadLocal务必调用remove()尤其在线程池场景里线程会复用如果不清理旧数据可能被下一个任务读到。我实际排查过一个用户信息串号的bug最终定位就是因为ThreadLocal没有在线程回收前remove用户名被下一个请求复用了。6.5 线程池参数不是背出来的是在场景里调出来的直接new Thread创建线程的方式在规范的项目代码里应该绝迹。线程池的好处在于线程复用、控制并发数、方便统一管理。ThreadPoolExecutor构建时有几个重要参数corePoolSize核心线程数即使线程空闲也不会被回收。maximumPoolSize线程池允许的最大线程数。keepAliveTime和unit非核心线程空闲多久后被回收。workQueue任务等待队列。threadFactory线程工厂统一设置线程名和是否daemon。handler拒绝策略。当提交任务时执行流程是如果当前线程数小于corePoolSize创建新线程如果线程数不小于corePoolSize任务放入队列如果队列满了且线程数小于maximumPoolSize创建非核心线程如果线程数已经达到maximumPoolSize触发拒绝策略。为什么这个顺序是“先入队再扩线程”因为线程本身有创建和上下文切换成本引入队列是为了缓冲突发流量避免无节制地创建线程。corePoolSize与maximumPoolSize之间的差值是为了应对短时高流量。拒绝策略有四种AbortPolicy默认抛异常CallerRunsPolicy让调用的线程自己去执行任务DiscardPolicy悄悄丢弃DiscardOldestPolicy丢弃队列里最老的任务。实际项目中比较建议自定义拒绝策略把任务持久化或打印告警。单纯抛RejectedExecutionException很可能造成数据丢失。CPU密集型和IO密集型任务的核心线程数估算可以参考这样的公式CPU密集型线程数约等于CPU核数IO密集型可以多配一点。但这只是粗略公式真正要可靠还是要看线上压测数据。我见过网上抄的“CPU核数1”配置直接套到IO密集应用上结果核心线程长期空闲性能上不去。复习时建议写一个带CountDownLatch的压测小demo对线程数做枚举试验会获得比死记公式更深的理解。7. JVM基础面试高频与线上排查的入口JVM是JavaSE复习的深水区也是Java后端岗位永远避不开的主体。如果已经复习到集合和并发那JVM的知识就可以串起前面学过的很多语言特性。7.1 运行时数据区域每一块都要说清干什么用JVM内存区域按线程共享还是线程私有来分比较好记。线程私有的包括程序计数器、虚拟机栈、本地方法栈随线程启动和消亡自然分配与释放。线程共享的包括堆和方法区JDK8叫元空间需要垃圾回收器额外管理。程序计数器记录当前线程正在执行的字节码行号这是实现分支、循环、跳转、异常处理和多线程切换后恢复执行位置的基础。虚拟机栈是Java方法执行时的内存模型每个方法调用创建一个栈帧栈帧里有局部变量表、操作数栈、动态连接、返回地址。递归调用过深最容易在这里抛StackOverflowError。堆是Java内存最核心的区域几乎所有的对象实例都在这里分配。堆内部可以划分为新生代和老年代新生代又分Eden区和两个Survivor区。绝大多数对象在Eden区创建经过Minor GC后存活的对象按年龄进入Survivor区多次存活后进入老年代。参数设置不用死记但要理解-Xms与-Xmx为什么建议设成一样的大避免运行期堆扩展造成性能抖动。JDK8之后方法区被元空间替代元空间使用本地内存不再复用堆内存。这样做的原因之一是永久代大小难以准确预估经常出现PermGen空间溢出改为本地内存后元空间大小默认只受物理内存限制不容易触发OutOfMemoryError但也更容易因为加载类过多耗尽操作系统内存。7.2 对象存活判断与垃圾回收算法判断对象是否存活主流做法是可达性分析。从GC Roots出发沿着引用链向下搜索没有到达路径的对象判定为可回收。GC Roots包括虚拟机栈中引用的对象、静态属性引用的对象、常量引用的对象、本地方法栈中JNI引用的对象。引用计数算法是很多教程提到但Java没有采用的方案原因很简单它解决不了循环引用问题。A引用BB引用A外部已经没有引用A或B了但两者的计数器都不为0。可达性分析天然绕开了这个问题。垃圾回收算法有标记清除、标记复制、标记整理三代演进。标记清除会产生内存碎片需要维护空闲列表分配复制算法适合存活率低的新生代把内存分成Eden和Survivor两段来回交换标记整理适合老年代将存活对象往一端移动避免碎片化。商用垃圾回收器是这些算法在不同场景的工程实现新生代用Parallel Scavenge、CMS老年代配合、G1将堆分成多个Region来管理ZGC则追求极低停顿。有一种常见误解是垃圾回收总在“所有对象都要回收时”触发其实各区域回收时机完全不一致。Eden满了触发Minor GC老年代满了触发Major GC或Full GC不同的垃圾回收器触发策略差异很大。排查线上Full GC频繁问题时先看堆空间各区域占比再看是否存在大对象或内存泄漏比直接调大堆参数靠谱得多。7.3 复习JVM时的思路建议我见过不少准备面试的人把CMS和G1的参数背了一长串但问到“哪种场景适合G1”“为什么G1能让停顿可预测”就回答不上来。这说明纯粹背知识点学JVM是没有效率的。更好的复习路线是先搞懂运行时数据区域和垃圾回收的基础然后通过现象倒推机制。比如线上出现OutOfMemoryError先用jmap导出堆转储再用MAT或JVisualVM分析看到大量相同对象说明可能是内存泄漏看到大数组或超大集合说明可能是内存分配不合理。这样复习一次整个JVM知识才真正串联起来。8. JavaSE复习季的常见问题排查实录与避坑汇总复习JavaSE的阶段做题、写demo、跑项目经常会碰到各种运行时报错。把这些问题记录下来不只是为了应付学习更是未来排线上bug的宝贵经验。下面整理几个我实际见过的高频问题每一条都给出症状、根因和有效处理路径。8.1 ConcurrentModificationException一边遍历一边改的大忌遍历集合时如果直接用集合的remove方法删除元素很容易抛出ConcurrentModificationException。原因是ArrayList等集合内部维护了modCount字段迭代器创建时会记录expectedModCount每次next都会检查两者是否一致一旦发现集合结构被修改就会快速失败。如果不是多线程并发的问题只是想在遍历时删掉满足条件的元素应该使用迭代器自身的remove方法IteratorString it list.iterator(); while (it.hasNext()) { String s it.next(); if (bad.equals(s)) { it.remove(); } }Java8之后更推荐直接用removeIflist.removeIf(s - bad.equals(s));这类异常从原理上理解快是因为迭代器设计时做了“快速失败”机制防止结构性修改导致遍历结果不可预期。看源码时能注意到fail-fast这一层设计哲学JavaSE才算是吃透了。8.2 StackOverflowError和OutOfMemoryError别上来就加内存StackOverflowError最常见于递归调用没有终止条件或者递归深度过大。如果是算法题可以考虑把递归改成循环如果是业务代码就要检查是不是存在A调用B、B调用A的无限循环。OutOfMemoryError的情形更复杂需要先用jmap看堆配置再用堆转储定位。我对初级开发者的建议是遇到OOM先别急着把-Xmx改大因为如果是内存泄漏改多大都会再次溢出。正确流程是保留现场、导出堆、分析泄漏点。8.3 死锁排查jstack是后端必备工具多线程程序出现卡死、吞吐量骤降时先用jstack打印线程快照。在线程快照里搜索deadlock或者检查线程状态大量停留在BLOCKED且互相等待同一个monitor基本就能确认死锁。死锁的产生需要四个必要条件互斥、持有并等待、不可剥夺、循环等待。解决办法通常是破坏循环等待条件比如让所有线程都按同样的顺序获取锁。定期检查线上服务的服务线程状况对排查问题会有很大帮助。8.4 线程安全集合的错误打开方式用普通HashMap做缓存多线程读写时会出现CPU飙高这在JDK7环境中尤其明显。HashMap在并发put触发的扩容过程中可能形成环形链表get时陷入死循环。解决办法就是并发场景换ConcurrentHashMap。复习时建议做一次并发读写HashMap的压测演示观察CPU占用和程序卡死现象这比口头强调“HashMap线程不安全”要有效得多。8.5 java.lang.ClassCastException与泛型误用泛型擦除后运行时强转会抛ClassCastException。一个典型场景是把从JSON反序列化得到的对象强转成某个泛型类。排查时先看类型是否匹配再看原始字符串对应的JSON结构是否真的符合目标类型。很多人遇到这种问题先怀疑框架其实根因往往是数据本身字段缺失或类型不一致。写一段单元测试打印对象实际类型问题会很快暴露。8.6 基础设施复习的实用工具清单除了IDE之外我复习JavaSE时还习惯用几个命令行工具辅助验证尤其是JVM和并发模块jps查看Java进程jstack查看线程快照jmap导出堆内存jstat查看类加载和GC统计。这些命令在Linux服务器上同样存在提前熟悉后面工作排查问题就会从容很多。还有一个小技巧是配合arthas这类在线诊断工具加深理解。比如用trace命令观测方法耗时用dashboard看线程状态和内存情况。工具用熟了抽象的内存区域和线程状态就会变成可视化的数据理解自然加深。最后一点关于复习节奏的经验分享JavaSE内容量大、理论多、考点碎。我的个人经验是不要试图用两周“背完”所有知识点而是分三轮推进。第一轮快速过语法和集合确保能流畅写出常用API第二轮认真看HashMap、ConcurrentHashMap、线程池和JVM相关的源码与原理自己画图记录第三轮做综合项目和笔试题集通过错题反查知识漏洞。如果在复习期间有条件建议每天抽半小时把当天的疑点写成一篇十几行的代码demo亲自验证运行结果。很多“看起来很懂”的知识点只有用代码跑一遍才能发现自己的判断和JVM实际行为并不一致。我自己常跟别人说JavaSE的复习没有捷径但也没有想象中那么可怕。把每一个“为什么”都往底层追一层这套地基打得越扎实后面学框架、读中间件源码、排查线上问题就越有底气。
返回列表