简介:这份 Java 基础知识笔试题及答案文档,面向正在准备笔试、面试或期末复习的 Java 初学者与进阶学习者,围绕语言基础与常见考点提供可直接练习的题目及参考答案。压缩包共 1 个 docx 文件,大小约 25KB,内容按基本概念、数据类型、运算符、控制结构、函数、数组、集合框架、泛型、封装与程序设计等模块组织,题型涵盖选择、代码运行结果判断、简答与编程实现。文档不仅给出答案,还针对 String、equals、Calendar、Set/Map/List、泛型 List 等易混点设置辨析题,并附字符串比较与拼接、日期计算、堆栈实现、日期间隔统计等参考代码,便于读者边做边对照思路。目前已有 1308 人学习,适合用作自测基础掌握程度、查漏补缺的材料,并可根据参考答案理解常见陷阱与编码规范;对准备课程考核与初级岗位笔试较为实用。
1. 从一道「输出结果是什么」说起:Java 基础笔试题到底在考什么
「Java 基础知识笔试题」这类材料,很多人第一次认真翻它,是在投简历前的那几个晚上。打开一看,满眼是「下面代码输出什么」「下列说法正确的是」,像是把一整本教材拆成了几十道选择题。打个比方,它更像驾考的科目一:不是让你真上路开车,而是用一批标准化的小判断,确认你对路标、档位、刹车位置这些基础件没有概念性误会。值得搞懂的原因也在这里——笔试题反复出现的那几个点,封装、包装类、集合、泛型、异常,恰好是后面写业务代码时天天要碰的东西。答错往往不是粗心,而是脑子里的模型本身就歪了:以为==能比字符串内容,以为Map和Set一样不能重复,以为泛型只是「写起来好看」。这份资料把题目和参考答案摆在一起,本质上是一张概念体检表,错的每一道都在告诉你哪块地基是空的。
2. 封装与包装类:为什么 Java 要给基本类型再包一层
2.1 封装到底封的是什么
封装这个词被说得太玄,其实它的画面感很强:一台自动售货机。你在外面只看到按钮、投币口和出货口,投三块钱按一下「矿泉水」,它掉一瓶出来。至于里面是弹簧货道还是履带、怎么计数、怎么找零,你完全不用知道,也没法伸手进去改。封装就是把这个「外面」和「里面」划清楚。
放到代码里,它包含两层意思:一是把数据项藏在对象内部,外部不能直接改;二是把方法的实现内容藏起来,外部只知道「调它能得到什么」,不知道「它怎么做到的」。用private修饰字段、用公开方法对外暴露有限的操作,就是最常见的做法。
public class Account { private double balance; // 外部拿不到这个字段,只能通过方法访问 public double getBalance() { return balance; } public void deposit(double amount) { if (amount <= 0) { throw new IllegalArgumentException("存入金额必须为正数"); } balance += amount; // 校验逻辑藏在方法内部,调用方不用管 } }这段代码里,balance被private挡住了,外部只能走getBalance()和deposit()。deposit()里那句金额校验就是「隐藏起来的实现细节」。哪天业务要求加一条「单笔不能超过五万」,你只改这一个方法,所有调用方一行都不用动。反过来,如果字段是public double balance,谁都能写一句account.balance = -1000,出了问题连从哪查起都不知道。
这就是封装真正交换到的东西:用一点「访问上的不方便」,换来「改动时的可控」。它是后面继承、多态能成立的前提,因为只有内部实现被藏住了,子类替换实现时外部才感知不到。
2.2 八种基本类型与它们的包装类替身
Java 有八种基本类型,它们不是对象,直接存值,速度最快。每一种都配了一个对应的包装类:
| 基本类型 | 包装类 | 常见默认值(字段或数组元素) |
|---|---|---|
| byte | Byte | 0 |
| short | Short | 0 |
| int | Integer | 0 |
| long | Long | 0L |
| float | Float | 0.0f |
| double | Double | 0.0d |
| char | Character | '\u0000' |
| boolean | Boolean | false |
注意char的包装类叫Character,是唯一不按「首字母大写」规律来的一个,考试里常在这里挖坑。
那为什么非要多此一举包一层?有三个很实际的理由。
第一,集合只收对象。你写不出List<int>,只能写List<Integer>。集合的底层是对象数组,基本类型塞不进去,必须先变成对象。
第二,泛型的类型参数只能是引用类型,这一条直接决定了上面那条。
第三,包装类身上挂着一批工具方法,parseInt、valueOf、compareTo、MAX_VALUE、MIN_VALUE都在这里,做类型转换时绕不开它们。而且包装类可以是null,能表达「这个值还没有」,基本类型做不到——它永远有个 0 在那里,你分不清是「真的是 0」还是「没填」。
顺带说一个真实存在的叫法混乱。很多题库会问「Java 中的封装类有哪些」,标准答案是Double、Integer、Float、Byte、Long、Character、Short、Boolean这八个。这里的「封装类」指的是包装类,是历史叫法,和 2.1 讲的「封装」这个面向对象特性完全是两码事。看到这个问法别慌,它问的就是包装类。
基本类型和引用类型「没赋值」时的样子也不一样,这一点在数组上看得最清楚:
int[] i = new int[5]; System.out.println(i[4]); // 0 Object[] obj = new Object[5]; System.out.println(obj[2]); // null数组一创建出来,每个位置就已经有默认值了,不需要你手动填。基本类型数组的默认值是 0(boolean是 false),引用类型数组的默认值是null。所以「没赋值」在两种数组里长得完全不一样:一个是 0,一个是 null。判断某个位置有没有被真正填过内容时,这个差别会直接影响你的逻辑。
2.3 装箱、拆箱与 valueOf:笔试题最爱的三个位置
把int变成Integer叫装箱,反过来叫拆箱。Java 允许你直接写Integer a = 100;,编译器自动帮你补上Integer.valueOf(100),这叫自动装箱;写int b = a;时自动补上a.intValue(),叫自动拆箱。表面上基本类型和包装类可以随便混用,实际上每次都在悄悄调方法。
这里面藏着一个特别容易翻车的点:
Integer a = 100; Integer b = 100; Integer c = 200; Integer d = 200; System.out.println(a == b); // true System.out.println(c == d); // false System.out.println(c.equals(d)); // true同样写法的两对变量,结果一个 true 一个 false。原因是Integer.valueOf()对 -128 到 127 之间的值做了缓存,落在这个范围里直接返回同一个对象,超出范围就老老实实 new 一个新的。a和b拿的是缓存里的同一个对象,c和d是两个不同对象。所以比较包装类的值,一律用equals,别用==。这段行为属于语言规范的细节,业务代码里记结论就够了,钻进去研究缓存范围反而容易把主线带偏。
第二个高频位置是String.valueOf这一族方法。它有一堆重载,参数不同走不同分支:
char[] data = {'a', 'b', 'c', 'd'}; System.out.println(String.valueOf(10D)); // 10.0 System.out.println(String.valueOf(3 > 2)); // true System.out.println(String.valueOf(data, 1, 3)); // bcd第一行传的是double,输出带小数点;第二行传的是布尔表达式的结果,输出true;第三行用的是(char[] data, int offset, int count)这个重载,offset是起始下标,count是取几个,从下标 1 开始取 3 个,就是b、c、d。记住offset和count的含义,这类「写出输出结果」的题基本不会错。
第三个位置是parseInt和valueOf的区别,尤其是那个进制参数:
int x = Integer.parseInt("200", 16); // 512,返回基本类型 int Integer y = Integer.valueOf("200", 16); // 512,返回包装类对象第二个参数是进制基数,不是「要转成什么进制」。这是初学者最容易搞反的地方:以为parseInt(s, 16)是把十进制转成十六进制,其实正相反,它是在说「把s这个字符串按十六进制来读」,读出来是十进制整数 512。要反过来把十进制转成十六进制字符串,用的是另一个方向的方法,比如Integer.toHexString(365)得到"16d"。手写一遍进制转换的循环有助于理解过程,但实际项目里调标准库就够了。
3. 集合框架的三分天下:List、Set、Map 各自守什么规矩
3.1 为什么有了数组还需要集合
数组有两个硬伤。一是长度固定,new int[5]就是五个位置,第六个元素放不进去;二是类型单一,一个数组只能装一种类型。日常场景往往两个条件都不满足:做一个通讯录,一开始根本不知道会存多少个同学,而且每个人除了姓名还有电话,是多个字段捆在一起的对象。
更麻烦的是数组太「裸」——它没有add、remove、contains这些方法,想找一个元素在不在里面,得自己写循环从头扫到尾。集合框架把这些事接了过去:容量不够时自动扩容、提供一整套增删改查方法、还能按不同需求选不同的实现。
这里有一层容易被忽略的历史原因:数组扩容要手动Arrays.copyOf,写起来就是一段又一段重复代码。集合把「容量管理」这件事从业务逻辑里彻底剥离出去,让写代码的人只需要关心「我要存什么」,不用关心「存满了怎么办」。
3.2 List、Set、Map 的存储规则对比
三个接口的定位可以用一句话概括:List管顺序,Set管唯一,Map管对应关系。
| 接口 | 元素形态 | 是否有序 | 能否重复 | 典型实现 |
|---|---|---|---|---|
| List | 单个对象 | 有序,可按下标访问 | 可以重复 | ArrayList、LinkedList |
| Set | 单个对象 | 通常不保证顺序 | 元素不可重复 | HashSet、TreeSet |
| Map | 键值对 | 不保证键的顺序 | 键不可重复,值可重复 | HashMap、TreeMap |
这张表里有几个点,考试和实际写代码都反复用到。
Collection是List和Set的共同父接口,但Map不继承Collection,它是独立的一支。所以「Map 继承 List」这种说法是错的,而「Map 的 value 可以是 List 也可以是 Map」是对的——键值对里的值本来就是Object,你想塞什么结构都行,嵌套一层Map<String, List<String>>完全合法。
Map的「不可重复」只约束键,不约束值。同一个键再 put 一次,不是新增一条,而是把旧值覆盖掉;而不同的键指向完全相同的值,一点都不冲突。比如一份成绩单,两个人都考 90 分是常态,但不会有两条记录用同一个学号。
Set的「不可重复」限制的是元素本身,同一个元素只能出现一次。这里稍微往深走半步:HashSet 判断重复的时候,先看hashCode再看equals。所以往 Set 里放自定义对象时,这两个方法要一起重写,否则两个「字段一模一样」的对象会被当成两个不同的元素存进去——这不是 Set 出了 bug,是你没告诉它「怎么算一样」。
3.3 一道 LinkedList 题暴露的索引边界
看这段代码:
import java.util.LinkedList; public class Test { public static void main(String[] args) { LinkedList list = new LinkedList(); list.add("A"); // 放到下标 0,此时 size = 1 list.add(2, "B"); // 试图插入到下标 2 String s = (String) list.get(1); System.out.println(s); } }正确答案是运行时抛异常,不是编译错误,也不是输出什么。原因在于add(int index, E element)这个重载要求0 <= index <= size。第一句执行完,size是 1,合法下标只有 0 和 1,传 2 就出去了,抛IndexOutOfBoundsException。
这道题的价值不在记住答案,在于分清「编译错误」和「运行异常」。编译器只看方法签名:add(int, Object)存在,参数类型也对得上,就放行。越不越界是运行时才检查的,因为它依赖size的实际值,编译器算不出来。凡是看到「给定如下代码,编译运行的结果是」,第一件事就是判断这个错误是编译期能发现的还是运行期才暴露的,这个判断做完,选项基本能排除一半。
同一类思维还体现在「用 LinkedList 实现栈」上。栈的特点是后进先出,而LinkedList提供了addFirst、getFirst、removeFirst三个方法,全部围绕「头部」操作,把头部当栈顶,行为就正好对上:
import java.util.LinkedList; class Stack { private LinkedList<Object> list; public Stack() { list = new LinkedList<>(); } public void add(Object obj) { list.addFirst(obj); // 压栈:新元素永远插到头部 } public Object get() { return list.getFirst(); // 看栈顶,不删除 } public void delete() { list.removeFirst(); // 弹栈:拿掉栈顶 } }这里没有用下标,全部靠「头部」这一个概念来定位,所以顺序天然是反的:先add进去的 1 被后来的 2、3 依次顶到后面,最后get出来的是 3。用addLast配removeLast也能实现同样的效果,关键是插入和取出必须从同一端操作,混着来就变成队列了。
再看一个Map的例子,它把「覆盖」这个行为演示得很直白:
import java.util.HashMap; import java.util.Map; Map<String, Integer> map = new HashMap<>(); map.put("A同学", 90); map.put("B同学", 83); map.put("B同学", 100); // 键已存在,值被覆盖,不会多出一条记录 System.out.println(map); // {A同学=90, B同学=100}输出里只有两条记录。put遇到已存在的键不会报错,它是「悄悄改写」。这个设计让Map很适合做计数、缓存、配置表这类「按 key 找 value」的事,但也意味着你没法靠put来发现重复——真想检查键在不在,得先用containsKey。
4. 泛型这层类型保险:为什么泛型 List 和普通 List 不是一回事
4.1 泛型要解决的是「取出来还得强转」的麻烦
在没有泛型的写法里,集合里装的全是Object。你塞进去一个字符串、一个整数,编译器都不管,因为Object是所有类的父类,谁都能往里放。麻烦出在取的时候:拿到的也是Object,想用字符串的方法就必须强转,而强转对不对,编译器不知道,只能等到运行时才炸。
import java.util.ArrayList; import java.util.List; public class RawVsGeneric { public static void main(String[] args) { // 普通 List:什么都能塞 List raw = new ArrayList(); raw.add("一句话"); raw.add(100); String s = (String) raw.get(1); // 编译通过,运行时抛 ClassCastException // 泛型 List:类型在编译期就被钉死 List<String> safe = new ArrayList<>(); safe.add("一句话"); // safe.add(100); // 编译期直接报错,根本到不了运行 String t = safe.get(0); // 不需要任何强制转换 } }对比一下两边的失败时机。普通 List 的错误发生在运行期,可能是上线三天后某个用户点到了才会触发;泛型 List 的错误发生在编译期,代码根本跑不起来。把错误从「运行时」挪到「编译时」,是所有类型系统的共同目标,泛型就是 Java 在这条路上迈的一大步。
4.2 类型参数是占位符,不是变量
泛型可以理解成对 Java 数据类型系统的一种扩展,让类可以按类型进行参数化。这里的关键词是「参数化」——平时写方法,参数传的是值;泛型传的是类型本身。
List<String>里的String就是填进List<E>这个模板空格里的东西。E是占位符,你在写List的时候不知道将来会填什么,只能用字母占着;真正实例化时才确定。所以List<String>和List<Integer>在源码层面是两个不同的类型,编译器按这个来检查你add进去的东西。
好处有三条,值得逐条记住:提高类型安全,编译期就能发现类型错误;消除强制类型转换,取出来直接就能用;提高代码重用率,同一套逻辑换个类型参数就能复用。
这里补一句通俗的解释:泛型的检查主要发生在编译期,代码编译完之后,类型参数信息会被「擦掉」,运行时的List<String>和List<Integer>在虚拟机眼里是同一个类。这也解释了一个初学时的疑惑——为什么不能写new T(),因为运行时根本不知道T是什么。知道这层「编译期更严格、运行期更模糊」的分工,很多泛型的怪现象就不那么怪了。
4.3 泛型方法和泛型类:什么时候用哪个
泛型类是把类型参数写在类名后面,整个类的字段、方法都能用它。做一个「一对值」的容器就很典型:
class Pair<T> { private T min; private T max; public Pair(T min, T max) { this.min = min; this.max = max; } public T getMin() { return min; } public T getMax() { return max; } }这里的T一旦在创建对象时定下来,getMin()的返回类型就跟着定了,不用强转。
泛型方法则是把类型参数写在返回值前面,只服务于这一个方法。求数组最大值最小值是标准例子:
class ArrayAlg { // <T extends Comparable> 是方法自己声明的类型参数,带一个上界 public static <T extends Comparable> Pair<T> minmax(T[] a) { if (a == null || a.length == 0) { return null; } T min = a[0]; T max = a[0]; for (int i = 1; i < a.length; i++) { if (min.compareTo(a[i]) > 0) min = a[i]; if (max.compareTo(a[i]) < 0) max = a[i]; } return new Pair<T>(min, max); } }<T extends Comparable>这一串不是装饰。方法体里调用了compareTo,编译器就必须确认T一定带这个方法,extends Comparable就是在给它这个保证。去掉上界,编译立刻失败。
对比另一个更宽松的泛型方法,把数组内容倒进集合:
import java.util.Collection; static <T> void fromArrayToCollection(T[] a, Collection<T> c) { for (T o : a) { c.add(o); } }这里只写<T>,没有任何上界,因为方法体里只做了add,而add是Collection的方法,跟T本身有没有特殊能力无关。判断该不该加上界,标准就一条:看方法体里对类型参数调用了什么方法,就补什么上界。用不到就不加,加了反而是给自己设限。
这两个例子合起来说明泛型的一个常见用法:Pair负责「装」,ArrayAlg负责「算」,装的部分和算的部分各自泛型化,组合起来就能同时处理整型数组和字符串数组,代码只写一遍。
5. 五个最容易踩的易混点:== 与 equals、Error 与 Exception 差在哪
5.1 误区一:== 和 equals 可以互相替代
易混点在于两者都用来「比较两个东西是否相等」。之所以混,是因为字符串那个最经典的例子会主动骗你:
String str1 = "It is"; String str2 = "It is"; System.out.println(str1 == str2); // true System.out.println(str1.equals(str2)); // true两个都是 true,于是很多人记住「== 也能比字符串」,然后在项目里踩坑。真相是:这两个字面量内容相同,编译器把它们指向了同一块常量区里的同一个对象,所以==比出来是 true,跟内容一点关系都没有。换成new String("It is")立刻现原形,==变 false,equals还是 true。
区分方法很简单:==用在基本类型上比值,用在引用类型上比「是不是同一个对象」;equals是Object定义的方法,默认也是比引用,但String、Integer这些类把它重写成比内容了。所以规律是——基本类型用==,对象比内容用equals,判断是不是同一个对象才用==。三者别串台。
5.2 误区二:Error 和 Exception 都是「程序出错了」
易混点在于日常说话时我们统称「报错了」,把两者混成一锅。之所以混,是因为它们确实同源:所有异常的父类是Throwable,Throwable下面才分成Error和Exception两支。很多人记住「父类是 Exception」,一考就错。
区分看的是「这事代码能不能管」。Error是内部错误、致命错误,比如硬盘坏了、驱动层面的问题,程序基本无能为力,业务代码里通常不去捕获它。Exception才是代码层面有处理空间的那一类,下面再分RuntimeException(除数为 0、数组下标越界、空指针这类)和其他受检异常。有的教材把它简化成「内部错误」和「运行时异常」两类,说的是同一个意思的不同切面。
这个分层还有个直接后果:多重catch里,父类Exception必须写在最后。因为它能接住所有子类异常,要是排在前面,后面那些更具体的catch永远轮不到,编译器会直接报错拦下来。
| 类型 | 在继承树上的位置 | 典型情形 | 业务代码该怎么办 |
|---|---|---|---|
| Error | Throwable 的直接子类 | 硬件、系统层面的致命问题 | 一般不在业务里捕获 |
| RuntimeException | Exception 的子类 | 除数为 0、下标越界、空指针 | 多半是代码 bug,去修而不是去捕 |
| 其他 Exception | Exception 的子类 | 需要显式 catch 或 throws | 必须处理或往上抛 |
5.3 误区三:finally 里的 return 不会影响返回值
易混点在于finally看着像「收尾工作」,大家默认它只是顺手清理资源,不该有权改结果。之所以混,是因为平时见到的finally确实都在关流、关连接,看起来人畜无害。
看这个方法:
public int count() { try { return 5 / 0; } catch (Exception e) { return 2 * 3; } finally { return 3; } }返回值是 3,不是 6。执行顺序是:try里除零抛异常,跳进catch算出 6 准备返回,但返回之前必须先执行finally,finally里又来一个return 3,直接把前面准备好的 6 覆盖掉了。
再看一个弱化版,把finally里的return去掉:
try { System.out.print("try,"); return; } catch (Exception e) { System.out.print("catch,"); } finally { System.out.print("finally"); }输出是try,finally。catch没进,因为没抛异常;finally进了,因为return生效之前它必须先跑完。这两道题放一起看,结论就清楚了:finally一定会在方法返回前执行,而它自己带return时,会把之前的返回值按下去。所以规矩是——try或catch里已经return了,finally里就只做清理,别写return,否则就是给自己留一个玄学 bug。
5.4 误区四:Map 和 Set 都「不能存重复元素」
易混点在于「不能重复」这四个字被粗糙地套在了两个不同的东西上。之所以混,是因为选择题里常把Set和Map并列成「不允许重复」的阵营,一起背下来最省事。
区分的关键是看「重复」约束的是谁。Set约束元素本身,同一个元素出现第二次就加不进去。Map只约束键,值完全不管:不同的键映射到同一个值,天经地义;同一个键再put一次,是覆盖旧值,不是报错,也不是新增。一份成绩表里两个人都考 90 分很正常,但不会有两条记录用同一个学号。
还有一层结构性差别:Map存的是键值对,它不属于Collection体系,没有继承List或Set。而Map的值可以是List,也可以是另一个Map,嵌套着用完全合法。把这三条分开看,「不能重复」这句话该安在谁头上就不会搞错了。
5.5 误区五:泛型方法对任何类型都能求最大最小
易混点在于T看起来等于「随便什么类型都行」。之所以混,是因为绝大多数泛型示例只写一个孤零零的<T>,给人一种「泛型 = 无限制」的印象。
区分看方法体里干了什么。只要调用了compareTo,就得写<T extends Comparable>,因为编译器要确认T身上一定有这个方法;只做add这类与类型无关的操作,<T>就够了。换句话说,泛型的类型参数不是「免检通行证」,它是「按需开权限」——方法体用到什么能力,就在类型参数上声明什么上界,一点不多加。判断时别盯着签名看,先看方法体对T用了哪些方法,答案自己就浮出来了。
6. 异常处理什么时候该用、什么时候别用:把 try-catch 放对位置
写catch之前,先问一句:这个异常是我控制不了的外部问题,还是我自己写出来的 bug?答案不同,处理方式完全相反。
外部问题指的是输入、文件、网络这些你没法替对方做主的环节。用户可能在要求填数字的地方填了「abc」,这时候捕获NumberFormatException并提示重新输入,是合理的:
import java.util.Scanner; public class SubDemo { public static void main(String[] args) { Scanner in = new Scanner(System.in); try { int a = Integer.parseInt(in.nextLine().trim()); int b = Integer.parseInt(in.nextLine().trim()); System.out.println(a - b); // 正常路径 } catch (NumberFormatException e) { // 外部输入不可控,捕获后给出可理解的提示,程序继续活着 System.out.println("输入的不是整数,请重新输入"); } } }NumberFormatException本身是RuntimeException的子类,按分类属于「可以不捕获」的那一类。但放在「输入来自键盘」这个位置上,捕获它是恰当的,因为它是可预期的、由别人引起的、你能给出替代方案的。
反过来,数组下标越界、空指针这些,本质上是代码写错了。捕获它只是把症状捂住,bug 还在,下次换个入口继续炸。这种情况下正确做法是去看那个下标是怎么算出来的、那个引用为什么是 null,把根因修掉。
| 场景 | 建议 | 理由 |
|---|---|---|
| 键盘输入、文件、外部数据 | 捕获并给出提示 | 内容不由你决定 |
| 数组越界、空指针、除零 | 修代码,不捕获 | 捕获只是隐藏 bug |
| 业务规则不满足 | 用返回值表达,必要时自定义异常 | 异常不该承担正常分支 |
| 需要释放的资源 | finally或 try-with-resources | 保证一定会执行 |
还有一种常见误用是把异常当流程控制。自定义一个异常类,在输入等于某个特定字符串时抛出去,技术上完全能跑,但如果这个「不符合预期」其实是很常见的正常分支,用返回值或状态码表达更清楚,也更省性能。自定义异常适合的是「调用方必须知道、而且必须停下来处理」的意外,不是「随手判个条件」。
我自己的习惯是:每写一个catch块,先确认它对应的是外部不可控因素,再确认块里做了有意义的事——要么提示、要么降级、要么包装成更上层能理解的异常往上抛。如果一个catch里只有一句e.printStackTrace(),那它基本等于没写,因为程序出问题时没人会去看那行输出。从那以后我每次写catch都强制走一遍这两问,踩过的坑少了很多。希望帮到你。
本文还有配套的精品资源,点击获取