
只要打开招聘软件搜“Java开发”你大概率会看到二十条JD里有十八条写着“Java基础扎实”。这句话听着像废话可在面试的时候基础扎实和不扎实的人三两轮就试出来了。我这些年以面试官身份见过不少候选人简历上写满Spring Boot、微服务、分布式结果一句“HashMap为什么用2的幂次扩容”就卡壳。反过来那些把数据类型、集合、并发这些最底层的东西吃透的人看框架源码的时候明显更从容。这篇东西我就从一个用了十年Java的老开发角度聊聊基础学习到底学什么、怎么实操顺便把大家常问的基础面试题和日常踩坑一起拆开看。1. Java基础到底在学什么先画一张知识地图1.1 数据类型与基本语法地基中的地基很多新手翻开Java教材第一章永远是“Hello World”。这当然没错但我更建议你先从数据类型开始建立框架感。Java是一门静态强类型语言所有变量在编译期就要确定类型。这个特点有两头好处一头是编译器帮你拦下大量低级错误另一头是你写代码时要时刻想清楚“这个变量到底存的是什么”。Python写顺手的人转Java最初最不适应的往往就是这个。Java的基本数据类型一共八种我列个表放在这里大家刚开始不用死记取值范围但有两点必须留个印象整数默认是int小数默认是doublelong和float的字面量要加L和F后缀否则编译不过。类型字节数默认值大致取值范围byte10-128 ~ 127short20-32768 ~ 32767int40约±21亿long80L很大float40.0f约±3.4E38double80.0d约±1.7E308char2\u00000 ~ 65535boolean1位falsetrue / false除了基本类型Java里还有引用类型。数组、String、各种对象都属于引用类型引用类型的变量里存的是对象的地址不是对象本身。可以这么理解基本类型变量是“拿着钱直接买东西”引用类型变量是“手里攥着一个遥控器遥控器指向某台电视”。两个引用变量用比较的时候比较的是两个遥控器是不是指向同一台电视而不是两台电视内容是否一样。能想清楚这个比喻后面学equals才不会糊。再来说包装类。集合里不能直接放int必须用Integer这就有了自动拆装箱。基础的考点集中在Integer缓存上Integer a 127和Integer b 127用 比较返回true换成128就返回false。原因很简单Integer内部对-128到127之间的对象做了缓存。类似的问题还会出现在String比较里。这类题目很八股但确实考察你知不知道“到底在比什么”。流程控制和数组部分我不建议单独死背。if、for、while用起来不陌生数组的唯一重点就是下标从0开始以及数组和集合之间的转换。基础语法里真正值得多花时间的是用两层for循环实现一个冒泡排序。它不是效率多高的算法但你亲手写一遍能彻底理解循环嵌套是怎么跑的这也是很多公司让候选人手写排序的原因。1.2 面向对象Java的核心不是语法是思想面向对象是Java的根。类与对象的关系我用图纸和汽车来理解类是图纸规定了车有什么属性、能做什么对象是按图纸造出来的那一台台具体的车。你每new一次就是照着图纸重新造了一辆车。图纸可以复用车可以造很多台这就是类与对象最朴素的含义。三大特性是封装、继承、多态。封装的意义是隐藏内部细节外部只能通过公开方法操作对象这样内部状态不会被随意改坏。继承解决的是共同代码复用但别滥用。我记得刚工作时总喜欢为了省代码硬造继承关系结果一个类改字段一串子类跟着崩。后来才明白优先用组合继承要非常克制。接口和抽象类是面试题常客。抽象类更像“半成品”里面可以放已经实现好的方法也可以留下抽象方法让子类补全接口更像一份“合同”规定实现类必须提供哪些能力。Java 8之后接口里也能写default方法但设计初衷没变一个类只能继承一个抽象类却可以实现多个接口。判断该用谁就看是想复用代码还是想定义能力。多态这点值得多说一句。父类引用指向子类对象调用方法时真正执行的是子类重写后的逻辑。听起来绕但你在代码中到处都能看到方法参数写成List实际传进去的可能是ArrayList也可能是LinkedList调用方不关心具体类型只关心它有List的能力。这就是多态带来的灵活度。重载和重写是两个概念重载发生在编译期、重写发生在运行期被问到时先分清这个基本就能过。基础里还有static、final、构造方法这些细碎概念。static让成员属于类而不是某个对象final给变量、方法、类上锁。构造方法负责初始化对象子类构造时必须先调用父类构造方法。这些点不太难但组合起来能出很多面试题比如static方法能不能重写、final类能不能继承。理解“属于类”和“属于对象”的差异大多数都能想明白。1.3 集合框架与常用API基础里的主战场集合是Java基础里最值得投入时间的部分没有之一。体系可以一句话记住Collection是单列集合的根接口下面有List、Set、QueueMap是独立体系存键值对。ArrayList用的是动态数组查询快、增删慢LinkedList用的是双向链表头尾增删快、随机访问慢。但实际开发里绝大多数场景选ArrayList就够了LinkedList的“优势场景”没那么常见。面试别把两者的复杂度背反。ArrayList扩容是拿着现有数组长度算出新容量一般是旧容量的1.5倍再通过Arrays.copyOf把老数据拷进新数组。这个知识点面试常考关键是“扩容是有代价的”所以如果预估数据量大初始化时直接指定容量能省掉反复扩容的损耗。底层没多玄妙能把new ArrayList()之后发生了什么讲明白基础这一关就算过半了。Map里最核心的就是HashMap。底层是数组加链表JDK 8之后在链表过长时改成红黑树。put一个键值对时先对key的hashCode做二次扰动再和数组长度减一做位与运算定位到桶哈希冲突了就往链表或红黑树里放。负载因子默认0.75意思是数组用了75%就扩容这是空间利用率和冲突概率之间折中的结果。为什么容量要求是2的幂次因为这样能用位运算替代取模扩容时元素重新落位也只需看新增的那个二进制位是0还是1。这些内容别死背打开JDK源码顺着代码走一遍记忆自然就深。Stream和Lambda现在也算基础了。比如把一堆订单按金额过滤再求总和用Stream写出来比传统for循环简洁得多。学习时不要只看语法要理解函数式接口这个概念Lambda本质是实现了某个函数式接口的匿名方法。StringBuilder在字符串拼接里的作用也要会对比后面面试篇我会专门拆。1.4 异常处理与泛型写健壮代码的底线异常处理看着简单写出问题却很多。Java的异常继承自Throwable下面分Error和Exception。Error一般指JVM层面的严重问题比如OutOfMemoryError正常代码不需要捕获Exception又分检查型和非检查型非检查型就是RuntimeException及其子类空指针、数组越界都属于这一类。编译期不强制处理运行时异常但这不代表你可以不处理该留心的还是要try-catch或者提前校验。try-with-resources是我特别想推荐给新手的写法。Java 7之后只要资源实现了AutoCloseable就可以写在try括号里作用域结束自动关闭。以前写文件流finally里关流又是一坨代码还要处理close本身抛异常现在这样写干净很多。还有一条铁律catch里不要吞异常。见过太多人catch(Exception e){}然后排错排到崩溃一查日志空白。至少得log.error或者把异常信息打印出来。泛型的核心作用是在编译期做类型检查。比如ListString只能放String编译期就把类型问题拦住了。这里有个容易懵的点类型擦除。JVM运行的时候ListString和ListInteger的运行时类型其实都是List泛型信息在编译后就被擦掉了。所以你不能用instanceof去判断一个List到底是String型还是Integer型。理解擦除之后泛型方法、泛型通配符这些概念学起来会顺很多。2. 从零开始的实操环境搭建、第一个程序与学习路线2.1 JDK安装与环境变量配置不可跳过的第一步配置环境永远是新手第一道坎。选版本先别纠结用LTS版本。JDK 8目前存量项目还很常见JDK 17和21是新项目的主流学习阶段选8或17都行。如果跟着老教程走就装8如果不想之后重新适应装17。我个人的建议是时间充裕就装17但一定要知道8的差异点因为很多公司线上项目还跑在8上。下载别随便在百度搜“JDK下载”点进不明站点。Oracle官方提供安装包Adoptium的Temurin、Azul的Zulu这些开源发行版也很好国内还可以用清华镜像或华为云镜像下载速度更快。记得认准x64安装包别下成ARM版。正确的下载地址比下载速度更重要很多奇怪的问题都是装到来路不明的魔改JDK之后才出现的这个坑我见得不少。Windows下配置环境变量右键“此电脑”→属性→高级系统设置→环境变量。新建系统变量JAVA_HOME值填JDK安装根目录比如C:\Program Files\Java\jdk-17然后在Path里新增一条%JAVA_HOME%\bin。保存后重新开一个命令行窗口分别执行java -version和javac -version两个能输出同一个版本号环境就算通了。Linux和macOS就改~/.bashrc或~/.zshrc设置JAVA_HOME和PATH用source命令重载。环境变量配置完不生效十有八九是没重开命令行多个JDK共存时JAVA_HOME被别的软件改了也会导致java -version和你预期不符。排查的时候先执行echo %JAVA_HOME%看指向是谁再用where java看实际调用的是哪个路径。这两个命令能解决80%的环境问题剩下20%基本是安装路径里有中文或空格重装到纯英文目录就好。2.2 第一个Java程序理解编译与运行有了环境先别急着打开IDE找一个纯文本编辑器写下这段代码public class HelloWorld { public static void main(String[] args) { System.out.println(Hello, Java!); } }在终端里执行javac HelloWorld.java如果没有任何输出说明编译成功目录下多了一个HelloWorld.class文件。再用java HelloWorld运行注意不要带.class后缀。这一前一后两个步骤你能看到Java的跨平台原理编译一次生成字节码之后只要目标机器装了对应版本的JVM就能解释执行字节码。很多教程直接让你点IDE里的运行按钮把这两个命令藏起来了导致很多人搞不清Class文件是什么。写代码工具我推荐IntelliJ IDEA社区版。社区版免费功能对学习阶段完全够用。新版本IDEA启动慢是通病把没用的插件禁用能快不少。它也内置了psvm、sout这些快捷模板新建类之后直接敲psvm回车主方法就出来了。不过基础阶段我建议偶尔关掉自动补全纯手写几段代码找找语法感觉不然依赖IDE太强面试笔试手写代码时会露怯。还有一个很多人忽略的点package和目录结构是对应的。比如你在代码里写了package com.example.demo这个.java文件最好放在com/example/demo目录下。IDE会自动帮我们维护但用命令行编译时搞错目录就会报“找不到类”的错误。理解这个对应关系以后看Maven项目结构会轻松很多。2.3 学习路线怎么排从语法到框架的合理坡度我按阶段拆一下学习路线。第一阶段是Java SE核心语法、面向对象、集合、IO、多线程、反射和JVM基础。这个阶段不用追求一次全会但集合和多线程至少要达到能解决常见需求的程度。第二阶段是数据库和基础工具MySQL的SQL语句、JDBC配合HTML/CSS/JavaScript简单了解。这个阶段可以做最简单的用户管理系统体验前后端交互。第三阶段是Java WebServlet、Tomcat、Maven了解HTTP协议和请求响应模型。第四阶段是主流框架Spring、Spring Boot、MyBatis用框架快速搭项目。第五阶段是中间件和项目实战Redis、消息队列、分布式基础做几个能写进简历的项目。我说点大实话如果你Java基础还很虚别急着上Spring Boot。框架的本质是工具工具用得再熟底层问题一出现还是会卡住。我见过太多简历写着“熟练使用Spring Boot”的人连Bean生命周期是什么都说不清。基础这一关没有必要跨过去也跨不过去。刷题网站我常用力扣和牛客。力扣的easy和medium对新手足够牛客上有很多Java面试题合集适合系统复习。但刷题不是目的遇到不会的要能反推到知识点。我的办法是建一个问答式笔记把问题写下来过几天不看资料自己回答一遍回答不出的地方就是薄弱点这个办法比盲目收藏好得多。也建议不要直接抄网上的课程设计源码自己分析需求、拆分模块、一步步实现收获完全不同。3. 高频Java基础面试题背后的原理拆解3.1 String、StringBuilder与StringBuffer三兄弟三个类放一起讲几乎是我面试必问的一组。String不可变底层在JDK 8是final修饰的char数组JDK 9及以后换成了byte数组。为什么不可变安全是一个原因String经常当参数传给文件路径、类名、网络地址如果可变这些场景的状态就不可控另一个原因是字符串常量池复用不可变才能安全缓存还有哈希值String对象可以缓存自己的哈希值频繁用Map时效率更高。字符串用拼接时编译器会把它转成StringBuilder的append链。注意单行语句没问题循环里每走一次都会new一个StringBuilder循环次数多了就会浪费大量对象。所以循环拼接字符串直接在外面new一个StringBuilder循环里append最后toString。对比项StringStringBuilderStringBuffer可变性不可变可变可变线程安全安全不安全安全方法加锁性能最差最高较低适用场景常量或少量拼接单线程、循环拼接多线程共享实际开发里方法内的字符串拼接直接用StringBuilder就够了因为局部变量没有线程安全问题。StringBuffer的同步锁反而成为累赘。3.2 与 equals以及 hashCode 的约定比较的是栈里的值。基本类型比较的是数值本身引用类型比较的是引用地址也就是那个遥控器指向哪台电视。equals是Object里的方法默认实现和一样比较地址但很多类重写了它比如String重写后比较的是字符内容。所以用比较两个String内容除非它们指向同一个常量池对象或intern过否则就是false业务比较用equals。重写equals必须同时重写hashCode。约定是equals相等hashCode必须相等hashCode相等equals未必相等。为什么必须有这个约定因为HashMap、HashSet这些容器靠hashCode先定位桶再用equals判断桶里到底是不是同一个对象。如果你只重写equals不重写hashCode同一个对象可能被丢进不同的桶contains就永远返回false。这是很多新手在集合里查不到对象的根本原因。public class User { private String name; // 只重写equals不重写hashCode // hashCode继承自Object默认是地址 }这个User放到HashSet里new两个name相同的对象第二次add都可能成功因为两个对象的地址不同。要把对象正确放入HashSet、HashMap的Key位置就必须成对重写equals和hashCode。3.3 HashMap 的底层结构与扩容机制HashMap是Java面试的重灾区网上八股一大堆。核心就几条底层是数组加链表JDK 8后链表长度超过8且数组容量不小于64时转红黑树put走hashCode二次扰动定位桶负载因子0.75容量始终保持2的幂次size超过数组长度乘0.75时扩容为原来的两倍。数组下标为什么用位运算而不是取模当n是2的幂次时n-1的低位全是1hash (n-1)就等价于hash % n但位运算更快。还有一个好处是扩容时元素迁移简单扩容后容量翻倍元素的新位置要么不变要么加旧容量。老版本HashMap并发扩容时可能形成循环链表这也是HashMap线程不安全的著名坑。多线程场景不要用HashMap直接用ConcurrentHashMap。JDK 8里它用CAS加锁加锁桶的方式来保证并发安全性能远好于HashTable。面试中常问的HashTable是全方法加锁现在基本不再使用。基础阶段知道这个演进过程即可不必把每个版本的实现细节都背下来。3.4 并发基础volatile、synchronized 与 AQS并发是Java基础的深水区也是拉开差距的地方。先理解Java内存模型线程操作数据时不是直接改主内存里的值而是先复制到自己的工作内存操作完再写回。这个机制会造成一种现象线程A改了变量线程B可能看不到最新值这就是可见性问题。volatile可以解决可见性和禁止指令重排问题但不保证原子性。典型用法是状态标志位一个线程改标志另一个线程读取加volatile就够了。但如果是count这种“读-改-写”的操作volatile就无能为力因为三个动作可以被打断。原子性要靠synchronized或者原子类比如AtomicInteger。synchronized是Java内置的锁可以锁对象、锁方法、锁类。JDK 6之后引入了锁升级偏向锁、轻量级锁、重量级锁。你不需要把每个细节背熟但要能解释清楚它本质上是互斥锁保证临界区内的原子性和可见性。日常开发中加锁范围越小越好能用局部变量就不用成员变量能用并发工具就不用synchronized。回到“Java怎么保证数据一致性”这个问题。要保证共享数据在多线程环境下一致靠的是原子性、可见性、有序性三个维度。synchronized和Lock解决原子性并保证可见性volatile解决可见性和有序性CAS是乐观锁思路适合竞争不激烈的情况。AQS是JUC锁和同步工具的底层骨架内部有state状态字段表示资源占用情况配合一个等待队列线程抢不到资源就排队等待抢资源的过程用CAS更新state。ReentrantLock、CountDownLatch、Semaphore都是基于AQS封装出来的。基础阶段理解到这个层面已经能超过不少工作了几年的人。3.5 手写基础冒泡排序与单例模式手写代码是面试基础关。冒泡排序的写法我建议背到不用思考就能写出来但不只是背要能讲清楚为什么外层循环控制轮数、内层循环控制比较范围。public static void bubbleSort(int[] arr) { int n arr.length; for (int i 0; i n - 1; i) { boolean swapped false; for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int tmp arr[j]; arr[j] arr[j 1]; arr[j 1] tmp; swapped true; } } if (!swapped) { break; } } }加上一个swapped标记某一轮没发生任何交换就提前结束这是最基础的优化。复杂度最好情况O(n)最坏和平均O(n^2)。很多人把复杂度说错其实只要想明白最坏情况下内层循环比较次数接近n(n-1)/2自然就记住了。单例模式有饿汉、懒汉、双重检查锁、静态内部类、枚举这几种写法。面试最爱问的是双重检查锁为什么加volatile。public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }instance new Singleton()在字节码层面不是一步而是分配内存、初始化对象、把引用赋给变量。如果没有volatile指令重排后可能先赋值引用对象还没初始化完成另一个线程拿到的就是个未初始化完成的对象。第一次判空为了省锁第二次判空保证在锁内只创建一次。这题考察的知识点覆盖了内存模型、指令重排、锁三级非常综合。4. 实战中的坑与排查实录4.1 环境与编译期的坑环境相关的坑我自己刚学的时候也踩过不少而且每一条都特别浪费人生。先说一个最经典的你按照教程配好了JAVA_HOME和PATH高高兴兴打开命令行跑java -version结果提示找不到命令或者显示的版本和刚装的JDK完全不一致。这种问题几乎每个初学者都遇过原因多半是命令行窗口没有重开或者系统变量和用户变量混在一块导致系统加载了旧配置。第二个高频问题是多个JDK共存。电脑里装过JDK 8又装了JDK 17明明JAVA_HOME指向17java -version却还是1.8。这时候用echo %JAVA_HOME%和where java两个命令排查基本能找到罪魁祸首。很多软件安装时会自动把自家的JDK路径写进PATH而且排在前面的优先被执行把%JAVA_HOME%\bin往前提一格就能解决。还有一类是编码问题。Windows控制台默认GBK代码文件是UTF-8直接命令行javac编译带中文的代码会报“编码GBK的不可映射字符”。解决方法是javac -encoding UTF-8 HelloWorld.java或者在IDE里把全局编码统一成UTF-8。这类问题别看小一旦出现会直接卡住学习进度。现象原因解决方式改完环境变量仍不生效命令行缓存未刷新重新打开命令行java -version和javac -version不一致PATH中存在多个JDKwhere java / where javac排查IDEA编译报source/target版本冲突项目SDK和编译器设置不一致统一Project Structure设置编译中文报错编码不统一加-encoding UTF-8或全局UTF-8排查环境问题的本质是分清两件事程序写错了还是环境错了。先跑最简单的HelloWorld再单独用命令行编译就能把范围缩小。不要一上来就怀疑电脑坏了大部分环境问题都是路径、版本、编码三件事引起的。4.2 编码过程中的经典坑编码层面的坑更多要么是并发修改要么是空指针。遍历List并删除元素新手最常见。用fori的时候删除一个元素后索引没回退后面的元素会跳过用foreach的时候直接抛ConcurrentModificationException。正确姿势是用Iterator的remove或者JDK 8之后的removeIf。之所以抛异常是因为foreach隐式用了Iterator而modCount被修改后预期的modCount对不上就触发fail-fast。这个机制不是设计缺陷是快速失败的防线。ListString list new ArrayList(Arrays.asList(a, b, c)); // 错误foreach删除 for (String s : list) { if (s.equals(a)) { list.remove(s); // 抛异常 } }自动拆箱空指针也是一个容易踩的坑Integer x null; int y x;直接抛NPE。通常发生在从Map里get一个不存在的value然后直接参与运算。所以对象转基本类型之前一定要先判空。Integer的比较也常见两个Integer用比较在-128到127之间走缓存返回true超出这个范围就是两个对象比的是地址。要比较数值用intValue()或者equals。还有switch的null处理。早期Java版本里switch表达式如果是null会直接抛NullPointerException。有人问“java switch 空数据”其实就是在问这个事。新版JDK里开始支持case null但你自己写业务代码时不要依赖这个新特性入口判空更稳妥。Arrays.asList返回的List也有点特殊它是最简单的固定大小列表本身是Arrays里的一个内部类不是java.util.ArrayList调用add或remove会抛UnsupportedOperationException。想转成可变列表用new ArrayList(Arrays.asList(...))包一层。4.3 学习方法上的坑与建议技能层面的坑可以查文档解决学习方法上的坑只能靠自己调整。我见过太多人每天看视频、记笔记一问到具体代码就懵。看一万行代码不如自己手写一百行。视频里老师写一遍你跟着写一遍好像会了但关掉视频让你从头写一个类似的小功能能写出来才是真会。建议每学一个知识点就给自己出一个小任务。源码阅读也是新手容易走偏的地方。一上来就逐行读HashMap源码读两天放弃。正确打开方式是先看类注释、核心字段和put/get方法的主流程自己画流程或者口述一遍再回来看细节。目标是建立心智模型不是背代码。还要善用调试器。在IDEA里给一段代码打上断点逐行看变量变化比抽象地想“这里应该是什么”直观得多。很多人觉得调试是工作之后才需要的能力其实基础阶段就练收益巨大。理解递归的栈过程、ArrayList扩容时数组的变化、HashMap put时桶的定位这些只用打印输出就能看清关键是你愿不愿意动手。最后是积累自己的代码片段库。我工作这些年最常用的工具类、异常处理模板、集合操作片段都存在本地文件里需要时直接改改就用。新手也可以这样每学一个API写一个小demo存起来过段时间翻出来就是自己的文档。这个习惯越早建立越受益。回到Java基础学习这件事我自己的体会是它不是一个学完就翻篇的章节而是一个会反复加固的底座。等你接触Spring Boot、微服务之后回头看很多困惑都会牵回基础。我每隔一段时间会重新读一遍HashMap和AQS源码每次都有新理解。如果你现在正卡在基础阶段别急着横向铺开把数据类型、面向对象、集合、并发这几块亲手写一遍、调试一遍比空看十遍教程有用。慢慢来反而快。