尧图精选

Java基础学习笔记:从环境配置到集合与并发核心知识点复盘

🕒 发布时间:2026/10/2 4:03:48 📁 来源:尧图网络
这个系列的Java学习笔记已经写到第1.16篇了。走到这个节点正好是从“能跑通代码”过渡到“想明白为什么”的阶段也是大多数人最容易放弃的阶段。前面十几篇都在补语法、写小练习到这一篇我想认真复盘一下从环境配置到基础语法从面向对象到集合容器再到定时任务、深浅拷贝这些面试常客到底哪些知识点是真正绕不开的。这篇笔记不是教科书式的罗列更多是我自己踩坑之后的记录。比如win11下环境变量配完cmd不生效、数组越界异常到底怎么定位、for-each循环里删除元素为什么报ConcurrentModificationException、冒泡排序写了几遍还是边界出错……这些场景都真实发生过。如果你也在学Java或者准备面试前想快速回顾核心基础这篇应该能帮你省下不少时间。1. 环境配置和第一个Java程序先把工具链理顺1.1 版本选择背后的真实考量很多人第一步就卡在JDK版本上。我的建议很简单新项目用JDK 17老项目用JDK 8不要纠结。为什么JDK 8是2014年发布的版本但它至今仍是很多企业生产环境的绝对主力因为Spring Boot、Hadoop这些生态对它的支持最成熟。JDK 17是下一个LTS长期支持版本垃圾回收、性能、语言特性都有明显提升新项目选它没有历史包袱。安装时要注意一件事JDK和JRE的关系。JDK 8之后Oracle不再单独提供JRE安装包JDK里已经内置了完整的运行环境别再额外装一个JRE到系统里否则后面版本冲突排查起来会很头疼。还有一种报错网上特别多人遇到You arent using a compiler supported by lombok, so lombok will not work.这个一般不是环境配置的问题而是Lombok版本和JDK版本不匹配。JDK升级到17之后老的Lombok版本会直接失效解决方法就是升级Lombok插件依赖到1.18.20以上IDEA里也要同步更新Lombok插件。1.2 JAVA_HOME、PATH与win11配置细节环境变量配置网上教程一搜一大把但真正踩过坑的人才知道细节在哪。我以win11为例梳理一遍完整流程下载JDK安装包安装路径建议不要带空格和中文比如C:\Java\jdk-17。新建系统变量JAVA_HOME值填JDK安装根目录注意是JDK目录不是里面的bin目录。编辑Path变量新增%JAVA_HOME%\bin。打开新的cmd窗口一定重新打开旧窗口不会刷新环境变量执行java -version验证。最容易出错的是这两点配置了JAVA_HOME但Path里直接用绝对路径C:\Java\jdk-17\bin之后升级JDK还得改两处没必要。java -version能出来但javac -version报错。这说明Path里指向的是别的JDK版本或者早期安装残留了别的JDK路径。排查方法是在cmd里执行where java看系统到底找到了哪个目录下的java.exe。CLASSPATH这个变量现在基本不需要手动配了JDK 1.5之后会自动加载当前目录下的类。网上有些老教程还在让你配.;%JAVA_HOME%\lib配了反而可能引发奇怪问题建议忽略。1.3 第一次编译就翻车类名、编码和main方法环境配好后第一个程序的坑通常集中在三处文件名必须和public类名一致。如果文件叫Hello.java里面写的是public class Hijavac Hello.java会直接报错。一个文件里可以有多个类但只能有一个public类且public类名要和文件名一致。中文乱码问题。Windows下经典编码误区你用记事本写的中文默认是GBK编码而javac在JDK 18之前默认按照平台字符集解码。如果文件是UTF-8编码编译时最好显式指定javac -encoding UTF-8 Hello.java运行时的乱码还要看控制台代码页chcp 65001切到UTF-8通常能解决。main方法签名不能错。必须是public static void main(String[] args)。少一个static程序能编译通过但运行时报Main method not found这个坑特别容易出现在手敲代码的时候。2. 数据类型、字符串与数组绕过基础语法里的暗坑2.1 基本类型和引用类型值传递还是引用传递基础语法里最高频的面试题就是“Java是值传递还是引用传递”。标准答案是Java只有值传递。基本类型传递的是值副本引用类型传递的是引用副本但很多人理解到这里就停了。看这个经典例子public class PassTest { public static void main(String[] args) { String s hello; change(s); System.out.println(s); // 输出 hello } public static void change(String str) { str world; } }为什么s没变因为str是s的一个引用副本给str重新赋值只是让这个副本指向了新对象s本身指向的还是原来的hello。但如果我在change里调用了str.toUpperCase()注意String不可变还是会返回新对象。真正能体现“引用传递错觉”的是修改对象内部属性比如传入一个Person对象把p.name改掉外面的对象属性确实会变。关于包装类还有一个高频坑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原因就是Integer默认缓存了-128~127区间的对象直接用比较的是对象地址。老实说这种题写业务代码时不会有人叫你这么写但面试官就爱问。记住一条原则包装类之间比大小永远用equals()。2.2 数组越界异常现场复盘数组越界异常ArrayIndexOutOfBoundsException几乎是新手必踩的坑而且很多人都是一眼看不出问题。我那次报错代码是这样int[] arr {1, 2, 3, 4, 5}; for (int i 0; i arr.length; i) { System.out.println(arr[i]); }当i增长到5时arr[5]已经超过数组最大下标4了于是程序抛出数组越界异常。问题就出在i arr.length这里应该是i arr.length。这个异常的本质是数组下标范围是0到length - 1Java虚拟机在字节码层面会插入检查指令aaload/aastore一旦越界就抛出异常。所以它不是语言层面的警告是运行时强制的边界保护。排查思路很简单异常堆栈里会明确告诉你Index 5 out of bounds for length 5先看下标再看循环边界最后确认是不是数组又被截断或者扩容了。2.3 判断字符串中是否不是字母和数字的正解这个需求看起来简单做起来容易漏边界。比如要判断一个字符串里是否存在“不是字母也不是数字”的字符新手第一反应可能是逐个遍历public static boolean hasSpecialChar(String str) { if (str null || str.isEmpty()) { return false; } for (char c : str.toCharArray()) { if (!Character.isLetterOrDigit(c)) { return true; } } return false; }用Character.isLetterOrDigit()是最直观的方式通用性好。但如果追求简洁也可以用正则表达式public static boolean hasSpecialChar(String str) { return str ! null str.matches(.*[^a-zA-Z0-9].*); }注意matches()要求整个字符串匹配所以前后要加.*。另外这个正则只认ASCII的字母数字中文会被当成“特殊字符”如果业务上希望中文也算合法字符那就还是用Character.isLetterOrDigit()靠谱因为Character判断包含了Unicode的字母和数字。2.4 标识符命名规则与规范标识符的硬性规则只有几条不能以数字开头、不能是Java关键字、大小写敏感、允许$和_。但真正让代码可读的是规范类名大驼峰UserService、OrderDetail方法名和变量名小驼峰getUserName、orderList常量全大写下划线分隔MAX_CONNECT_COUNT包名全小写com.example.project在面试和实际项目中命名规范是代码评审的重点。见过太多变量叫a、b、temp过两周自己都看不懂。我个人的经验是变量名长一点没关系但一定要表达意图。比如numberOfUnreadMessages比num强一百倍。3. 面向对象与设计模式从写类到设计类3.1 封装继承多态其实就是三句话面向对象编程OOP是Java的基石但很多人学到后面把概念背得很溜写代码时还是面向过程。我用三句话总结封装不想让别人看到的东西藏起来给别人看的东西开个确定的门。继承子类复用父类的属性和方法同时可以扩展自己的行为。多态同一个方法调用在不同对象上表现出不同行为。多态最典型的使用场景是接口。业务代码里要扩展一个功能应该优先面向接口编程而不是面向具体实现。比如支付定义一个PaymentService接口下面有AliPayService和WechatPayService调用方只依赖接口。后面再加一个UnionPayService调用方代码一行都不用改。这就是多态带来的好处也是设计模式的核心思想。3.2 static与初始化顺序很反直觉的一块这部分在面试里出现频率非常高我自己也答错过。看这段代码public class InitOrder { static { System.out.println(1.静态代码块); } { System.out.println(2.实例代码块); } public InitOrder() { System.out.println(3.构造方法); } public static void main(String[] args) { new InitOrder(); new InitOrder(); } }输出结果是1.静态代码块 2.实例代码块 3.构造方法 2.实例代码块 3.构造方法规律是静态代码块只在类加载时执行一次每次创建对象时先执行实例代码块再执行构造方法。如果有父类初始化顺序是父类静态代码块 - 子类静态代码块 - 父类实例代码块 - 父类构造方法 - 子类实例代码块 - 子类构造方法。这个顺序背后对应的是Java类生命周期和对象创建流程建议理解记忆而不是死背。3.3 从重写equals和hashCode入手理解约定Object类里的equals()默认比较的是对象地址但业务上两个User对象只要id相同就算同一个用户。所以必须要重写equals()。这里有个强制约定重写equals()时必须重写hashCode()。为什么因为HashMap、HashSet这类基于哈希的集合先通过hashCode确定存储位置再通过equals判断是否相等。如果两个对象equals返回true但hashCode不同那么它们会被放进不同的桶HashMap中就会出现两个相等的key逻辑彻底崩掉。我的习惯是用IDEA自动生成equals()和hashCode()生成时选业务主键字段别图省事用对象里所有字段。判断相等只看业务主键这样才能保证“同一个用户”在集合里不重复。3.4 设计模式先学单例和工厂设计模式一共有二十多种新手不要贪多。我建议先掌握两个单例模式和工厂模式。单例模式最常用的写法是双重检查锁加volatilepublic 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; } }这里volatile有两个作用一是保证可见性二是禁止指令重排。new Singleton()不是原子操作它分三步分配内存、初始化对象、把引用赋值给变量。编译器可能重排成先赋值引用再初始化对象另一个线程就会拿到未初始化完成的对象。volatile禁掉了这个重排保证拿到的一定是完整的对象。工厂模式的价值是“把创建对象的逻辑和使用对象的逻辑分离”。最简单的是静态工厂public class PaymentFactory { public static PaymentService create(String type) { if (ali.equals(type)) { return new AliPayService(); } else if (wechat.equals(type)) { return new WechatPayService(); } throw new IllegalArgumentException(未知支付类型); } }调用方不需要知道具体实现类只跟PaymentService接口打交道。后续扩展新支付方式只需在工厂里加分支调用方不动。这就是设计模式“对扩展开放对修改关闭”的直观体现。4. 集合容器、排序实现与循环里的性能坑4.1 集合选型先问三个问题Java集合容器类非常多选型时我一般先问自己三个问题需不需要保证顺序元素是否允许重复是否涉及多线程并发操作容器类是否有序是否允许重复线程安全ArrayList有序按插入顺序允许否LinkedList有序按插入顺序允许否HashSet无序不允许否LinkedHashSet有序按插入顺序不允许否TreeSet有序按自然顺序或比较器不允许否HashMap无序key不允许否LinkedHashMapkey有序插入顺序或访问顺序key不允许否TreeMapkey有序key不允许否ConcurrentHashMap无序key不允许是大部分业务场景用ArrayList和HashMap就够了。LinkedList很少用到它只有在频繁头尾插入删除时才有优势但实际业务里这种场景极少。如果多线程操作同一个集合优先考虑ConcurrentHashMap和CopyOnWriteArrayList而不是给普通集合加synchronized。4.2 从扩容机制看ArrayList和LinkedListArrayList底层是动态数组new ArrayList()默认容量是10每次扩容会变成原来的1.5倍也就是新容量等于oldCapacity (oldCapacity 1)。扩容需要把旧数组的元素复制到新数组频繁扩容会有性能损耗。所以如果一开始能估算元素数量应该用new ArrayList(expectedSize)指定初始容量。LinkedList底层是双向链表插入删除只需要修改节点指针不需要搬移数据。但是链表的缺点是内存占用大每个节点要额外存两个指针而且随机访问需要从头遍历get(index)的复杂度是O(n)。所以“LinkedList插入快”这种说法是有前提的只在已知位置插入时快如果在集合中间插入光找到位置就要O(n)。我实际写代码时99%的场景都用ArrayList它的局部性好遍历快内存占用小。4.3 HashMap原理扫盲HashMap是面试的必考点也是实际开发中最常用的容器。核心原理就三件事哈希key的hashCode()经过扰动函数hash ^ (hash 16)得到hash值再通过(n - 1) hash计算数组下标。冲突当两个key的hash落到同一个桶时用链表存储链表长度超过8并且数组长度达到64时链表转成红黑树查询复杂度从O(n)降到O(log n)。扩容加载因子默认0.75当元素数量超过容量 * 0.75时扩容为原来的2倍重新计算所有元素的桶位置。加载因子为什么是0.75这是时间复杂度和空间复杂度之间的平衡。加载因子太大比如1空间利用率高但哈希冲突变多查询变慢加载因子太小比如0.5冲突少但空间浪费严重。0.75是官方测试后的经验值。理解这些之后面试时被问“HashMap为什么不安全”也能答得有条理多线程put时可能出现数据覆盖JDK 7里还会在扩容时形成环形链表导致死循环JDK 8修复了死循环问题但数据覆盖依然存在。4.4 冒泡排序的标准写法与优化排序算法里最基础的是冒泡排序面试中偶尔会让手写。标准写法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 temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; swapped true; } } if (!swapped) { break; } } }两个细节容易出错内层循环边界是n - 1 - i因为每一轮都会把当前最大值放到末尾末尾的元素不需要再参与比较swapped标记用于优化如果一轮下来没有发生交换说明数组已经有序直接跳出循环。冒泡排序的时间复杂度平均O(n²)空间复杂度O(1)是稳定排序。实际业务中很少直接用冒泡排序但手写它是考察基础功底最直接的方式。4.5 for-each遍历时删除元素会抛异常这个坑在真实开发里踩的人太多了。看这段代码ListString list new ArrayList(Arrays.asList(a, b, c)); for (String s : list) { if (b.equals(s)) { list.remove(s); } }运行时会抛ConcurrentModificationException。原因是for-each本质上是使用Iterator遍历list.remove()只是修改了集合的modCount迭代器内部的expectedModCount没变遍历时检查两个值不一致就抛异常。正确做法是用Iterator自己的remove()方法IteratorString iter list.iterator(); while (iter.hasNext()) { String s iter.next(); if (b.equals(s)) { iter.remove(); } }或者用JDK 8的removeIf一行搞定list.removeIf(b::equals);这也是我推荐的写法直观、安全、代码最少。5. 异常处理与代码健壮性一次越界和空指针的排查思路5.1 异常体系别一见Exception就catchJava的异常体系分两大类Error和Exception。Error如OutOfMemoryError、StackOverflowError表示JVM层面的严重问题一般不建议捕获处理该放开就放开Exception又分为受检异常checked和非受检异常unchecked/runtime。受检异常是编译器强制你处理的比如IOException、SQLException必须catch或throws。非受检异常包括NullPointerException、ArrayIndexOutOfBoundsException等编译器不强制处理但实际运行时最容易出现。新手最容易犯的错是到处catch大范围异常然后打印一下就算了。这会掩盖真正的逻辑问题。正确姿势是能预判的边界情况先判断避免异常发生真发生了异常要记录完整堆栈而不是只打一句话。5.2 空指针的三种常见来源空指针异常NPE是Java里出现率最高的异常。我的经验是NPE通常有三个来源方法返回值是null调用方没做判断就直接调用了返回对象的方法。自动拆箱遇到null比如Integer num null; int n num;直接抛NPE因为刚说了包装类没有值传递拆箱时会调intValue()。数组或集合取出的元素是null循环里直接调用元素方法。防御手段平时就要用起来方法入参用Objects.requireNonNull(obj, obj不能为空)尽早暴露问题。返回集合时别返回null返回空集合Collections.emptyList()。调用链长的地方注意用Optional但Optional也别滥用它适合返回值可能为null的情况不适合做字段类型。5.3 一个真实排查案例越界和空指针连在一起有一次我调试一个订单导出的功能日志里先报了ArrayIndexOutOfBoundsException我盯着堆栈找半天发现不是数组本身越界而是对String.split()的结果取下标时越界了String[] parts data.split(,); String orderId parts[0];如果data本身就是空字符串或者不以逗号分隔split()返回的数组长度可能是0或者1parts[0]就崩了。这类问题的排查思路其实很固定先看异常是哪一行抛的再看这一行访问了哪个数组或集合然后回头确认被访问的容器长度。日志里如果有数据内容记得看是不是脏数据。还有一个高频坑是数组长度不够但业务上需要固定位数的字段比如parts长度只有2但代码访问了parts[2]。我后来的习惯是取完split()先判断长度String[] parts data.split(,, -1); if (parts.length 3) { // 记录脏数据返回错误提示 }split(,, -1)保留末尾空字符串比默认的split(,)更不容易丢字段。5.4 记录日志比看堆栈更重要排查线上问题时最痛苦的是日志里只有一行NullPointerException没有堆栈。这里有个习惯问题很多人写catch时只打e.getMessage()但NPE的message往往是null。正确写法是catch (Exception e) { log.error(处理订单失败orderId{}, orderId, e); }日志框架SLF4J Logback的这个写法e放在最后一个参数能打印完整堆栈。orderId这样的上下文信息一定要带上否则日志里只有异常没有当时的业务数据排查起来两眼一抹黑。6. 从笔记走向面试题容器、定时任务与数据一致性6.1 定时任务别急着上框架Java做定时任务初级认知可能只知道Timer然后就被面试官追问“Timer的坑”。确实Timer是单线程调度任务执行时间过长会阻塞后续任务而且子任务抛异常会导致整个Timer线程终止后续任务全部取消。所以不用Timer是对的。现代的写法直接看技术栈场景推荐方案纯Java、轻量级ScheduledExecutorServiceSpring Boot 项目Scheduled注解 ThreadPoolTaskScheduler需要分布式调度Quartz / XXL-Job / ElasticJobScheduled用起来最简单Component public class TaskJob { Scheduled(cron 0 0 2 * * ?) public void cleanExpiredData() { // 每天凌晨2点执行 } }要注意的是Scheduled默认单线程执行。如果一个项目里多个定时任务会互相等待。解决方法是配置一个线程池类型或者直接自定义TaskScheduler。面试问到定时任务能说清楚单线程阻塞问题、cron表达式、分布式场景下怎么保证不重复执行基本就够了。6.2 深拷贝和浅拷贝的实现取舍深浅拷贝也是高频考点。先说结论浅拷贝只复制对象本身内部的引用类型字段仍指向同一个对象深拷贝连内部的引用对象也一起复制一份。Java里实现拷贝有三种常见方式第一种重写clone()public class User implements Cloneable { private String name; private ListString tags; Override protected Object clone() throws CloneNotSupportedException { User copy (User) super.clone(); copy.tags new ArrayList(tags); // 手动拷贝引用字段 return copy; } }注意如果tags直接复制引用那就是浅拷贝。引用字段必须在clone()里手动新建。这是最灵活的方式但要逐个字段处理。第二种通过序列化public User deepCopy() throws Exception { ByteArrayOutputStream bos new ByteArrayOutputStream(); ObjectOutputStream oos new ObjectOutputStream(bos); oos.writeObject(this); ByteArrayInputStream bis new ByteArrayInputStream(bos.toByteArray()); ObjectInputStream ois new ObjectInputStream(bis); return (User) ois.readObject(); }这种方式简洁但要求所有字段类型都实现Serializable性能也比手动拷贝差适合拷贝层次很深、字段很多的对象。第三种手动复制创建一个新对象把所有字段都赋一遍。最笨但最可控不会出现引用共享的问题。业务开发中我的建议是能不用clone()就不用容易留下引用共享的隐患。用构造器或者工厂方法手动创建更清晰。6.3 数据一致性这个面试题到底在问什么热搜词里有个问题“java怎么保证数据一致性”很多初学者看到就懵了数据一致性不是数据库的事务概念吗和Java有什么关系其实面试官问的是两层第一层是单机多线程的数据一致性。多个线程同时对一个变量读写怎么保证不超过预期核心是三个关键字volatile保证可见性和有序性synchronized保证原子性和互斥Lock如ReentrantLock提供更灵活的同步。平时写业务代码多线程操作共享变量先想想能不能用局部变量实在要用共享变量就上锁。第二层是单体应用内部多线程对数据库的操作。比如库存扣减多个请求同时扣减一个商品的库存光靠数据库行锁不够还需要在应用层做控制。常见方案是对库存操作走synchronized或者Redis分布式锁再配合数据库乐观锁UPDATE product SET stock stock - 1 WHERE id #{id} AND stock 0这个SQL本身就能保证不会扣成负数。理解数据一致性不要一头扎进分布式事务先把单机和单库层面搞明白才是正确的学习顺序。6.4 Java学习路线复盘八股文不是背的最后聊聊“Java八股文”和“Java学习路线”这两个热词后面的事。我看到很多人刷面试题背得滚瓜烂熟但问他“为什么HashMap加载因子是0.75”就答不上来。八股文本身没有错错的是只记结论不记推导。我把学习路线重新排了一下让每个知识点都能串起来基础语法数据类型、流程控制、数组、字符串先做到能写小程序。面向对象封装继承多态、接口、异常把代码写“像样”。集合框架ArrayList/HashMap原理、迭代器、排序把代码写“高效”。JVM基础内存区域、类加载、垃圾回收理解Java为什么是“跨平台”的。并发编程synchronized、volatile、ThreadLocal、线程池解决“多线程”问题。框架阶段Spring、Spring Boot、MyBatis把项目跑起来。中间件阶段Redis、MQ、Elasticsearch等解决“分布式”问题。每一步学完要有对应的输出。我的输出方式就是这套学习笔记。写到第1.16篇的感受是很多当时觉得抽象的概念在自己动手写过一遍、踩过一遍坑之后突然就通了。比如HashMap看书时我觉得“链表转红黑树”不过是概念直到我调试一个死循环问题把容量和负载因子调来调去才真正理解什么是“空间换时间”。如果你也正在学Java我建议不要急着追新技术先把基础打牢。遇到报错先自己读堆栈再复制到搜索引擎最后写进自己的笔记。这样一步步走下去比收藏一百条所谓“学习路线图”都管用。这期笔记就到这里下一期我打算专门整理一次JVM内存区域的实战笔记把线上排查的真实案例一起写进去。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →