臭名昭著aTrust隔离计划1.臭名昭著aTrust隔离计划2.VPN原理与aTrust隔离网络实践3.docker-easyconnect到底做了什么4.TUN(tunnel-隧道-虚拟网卡)模式
个人项目
personal(个人展示)GitHub Actions 自动部署个人展示项目
基于Quartz搭建的个人博客1.Quartz个人博客使用教程2.使用 rsync 增量部署 Quartz 博客3.使用 GitHub Actions 自动部署 Quartz 博客5.域名绑定6.CDN加速7.Google 搜索收录排查:为什么关键词搜得到别人,却搜不到我的网站?Obsidian、双向链接与知识图谱
架构设计
分布式分布式事务RPC原理
旅行
行程攻略青岛三日游行程攻略
前端
NginxNginx配置与反向代理入门
Nodenpm与npx的区别
数据库
mysql01.MySQL基础语法与约束02.MySQL数据库类型03.MySQL多表查询04.MySQL 索引05.MySQL 事务06.MySQL 隔离级别07.MySQL MVCC08.MySQL 锁09.EXPLAIN10.慢查询与 SQL 优化
mysql面试题01.索引问题02.事务与隔离级别03.锁机制04.存储引擎与性能优化05.用户权限、字符集与备份恢复
mysql实战01.EXPLAIN实战02.测试环境菜单误删排查与恢复03.MySQL IN 详解
网络
网络安全1.无线监听网卡苹果M系列虚拟机安装Kali Linux教程3.kali怎么使用RTL8812AU4.kali基础命令
网络基础进程之间怎样通过网络传递数据
网址常用网址
中间键
esElasticsearch 入门
mq
RabbitMQRabbitMQ高级RabbitMQ基础
1. MQ 基础2. MQ 核心角色与代码对应3. MQ Consumer Group 与消费模式4. SOFAMQ 账户变更代码实践5. MQ 可靠性问题6. 消息幂等
redis缓存击穿、穿透与雪崩Redis 分布式锁Redis 缓存问题Redis 数据结构
AI
使用说明
第三方插件&技能简介Archify使用与安装指南geekgeekRun使用OfferFlow使用指南Ponytail使用指南Skills Manager使用指南
CodexCodex CLI与IDE区别及使用指南Codex Hook单独配置与提交通知Codex MCP安装与使用指南Codex第三方插件安装与使用指南
Agent开发0-学习路线1.LLM、Token、上下文窗口与模型参数2.大模型 API、请求参数与响应结构3.Spring AI ChatClient4.Prompt、System Prompt、Prompt 模板6.结构化输出、JSON Schema7.SSE 流式响应8.会话 ID、聊天记录、Redis9.超时、重试、限流、降级10.*完成可运行聊天接口11.Function Calling、Tool Calling 原理12.用 Java 方法定义 Tool13.工具参数设计和校验14.实现订单查询工具15.实现物流查询工具16.实现库存查询工具17.多个工具自动选择18.工具异常、超时、重试
diy主机
主机零件学习1.CPU2.主板3.内存4.显卡5.硬盘6.散热7.风扇8.机箱热门型号价格表
捡垃圾diy-13600kf+5060diy-14600kf+5070
English高频词汇英文基础语法简单句子音标
GitGit常用命令Sublime Merge 使用指南
Java
工具库Caffeine本地缓存Reactor教程
工作实践如何使用@Async线程池使用与排查线上服务 CPU 使用率过高排查压测下RPC接口大量失败排查记录
基础1.HashMap2.ConcurrentHashMap3.HashSet4.final5.static6.volatile7.AtomicInteger8.String动态代理反射锁与线程同步线程注解
面试题1.HashMap 存储在方法内是线程安全的吗2.ArrayList参数传递与引用重新赋值3.== 和 equals 的区别、hashcode4.重载和重写的区别5.String、StringBuffer、StringBuilder区别及使用场景6.接口和抽象类7.ArrayList 与 LinkedList 的区别和扩容机制8.HashMap 和 Hashtable 的区别及底层实现9.Object 和 Objects 的区别10.ThreadLocal的原理和使用场景11.字节码是什么及作用12.Java类加载器有哪些13.双亲委派模型14.Java异常体系15.GC是什么16.GC如何判断对象可以被回收
面试总结0.HR面试模板1.面试一2.面试二3.面试三笔试
网络与 IO(黑马程序员-纯理论)01-网络编程基础02-IO模型与零拷贝03-BIO04-NIO基础与缓冲区05-NIO通道与选择器06-AIO
juc00-目录01-进程与线程02-线程同步与通信03-并发设计模式04-Java内存模型05-CAS与原子类06-ThreadLocal07-阻塞队列08-线程池使用与原理09-任务调度10-ForkJoin与享元模式11-AQS12-ReentrantLock13-读写锁14-并发协作工具15-ConcurrentHashMap16-CopyOnWrite与同步容器17-SkipListMap18-非阻塞队列
juc(工作常见)Java并发编程三个重点拆解
jvm1. JVM运行时数据区
Spring1.Spring IoC、DI与构造方法注入CGLIB、Byte Buddy 与 Spring AOPSpring 常用注解详解Spring Bean 生命周期
Spring MVC异常处理机制
linux常用命令
mac快捷键终端常用操作
python
基础01.Python是什么,为什么要安装Python02.Python项目中.venv是什么03.Python基础语法
基础实战sql替换
HTTPXHRMS员工详情接口调用(Python HTTPX)
windowswindows激活

按知识类别整理笔试题,保留原题编号,补充参考答案与解析。目前收录 SQL 和 Java 类题目。

一、SQL 类

1.1 第 8 题:联合索引的列顺序与查询效率

题目

表 T 中,C1 列的不同值数量为 100 万,C2 列的不同值数量为 100。执行下面的 SQL 时,关于索引的说法正确的是:

SELECT COUNT(*)
FROM T
WHERE C1 = :1 AND C2 = :2;

原图中的“差异值”按“不同值数量(基数)”理解;:1、:2 表示绑定参数。截图未指定数据库产品与索引类型,以下按常见 B-tree 联合索引讨论。

选项内容
A索引 (C1, C2) 和索引 (C2, C1) 效率一样
B索引 (C1, C2) 比索引 (C2, C1) 效率高
C索引 (C2, C1) 比索引 (C1, C2) 效率高

考点

联合索引:两个字段都用 = 查询时,索引列的先后顺序是否影响查找效率。

答案

这道题选 A:两种索引查起来基本一样快。

先抓住一个关键点:题目里 C1 和 C2 的值都给了,而且都是用 = 查找。

解析

把题目换成“查学生”,就好理解了。

假设有一张学生表:

  • C1 是“学号”,有 100 万个不同的学号。
  • C2 是“校区”,只有 100 个不同的校区。
  • 索引就是数据库提前整理好的“查找目录”,用来快速找到记录。
  • 联合索引就是把两列放在同一份目录里,并按先后顺序排好。

现在要查:学号是 12345,并且校区是北京的学生有几个?

原题里的 :1、:2 就是要传进去的具体值,例如学号 12345 和校区北京;COUNT(*) 就是数一数符合条件的记录有几条。

下面两份目录,存的是同一批学生,只是排列顺序不同:

目录一:先按学号,再按校区目录二:先按校区,再按学号
12344,北京北京,12344
12345,北京 ← 要找的记录北京,12345 ← 要找的记录
12346,北京北京,12346
12347,上海上海,12347
  • 用 (学号, 校区) 索引,查找目标是 “12345,北京”。
  • 用 (校区, 学号) 索引,查找目标是 “北京,12345”。

两条信息都知道,两份目录都能用这两条信息定位到目标所在的位置。 如果有多条记录符合条件,再把这些记录数出来。

这里的表格只是帮助理解排列方式。真实数据库会沿着索引快速查找,不会像人看名单一样从第一行读到最后一行。

为什么不能说“学号不同值多,学号放前面一定快”?

你可能会这样想:

先查学号,一下就剩很少的人;先查校区,还剩很多人。所以学号放前面更快吧?

这个想法把联合索引想成了“两次分开的筛选”。

实际上,(校区, 学号) 这份目录已经把同一个校区里的学号排好了。数据库知道“北京”和“12345”,就能在目录里查找这个组合,不用把北京校区所有学生都读出来,再挨个检查学号。

所以,本题里“100 万”和“100”很容易让人误选 B。它们表示不同值的数量,但仅凭这两个数字,不能说学号放前面一定更快。

那什么时候前后顺序会影响查询?

把条件减少一个,区别就明显了。

只知道校区是北京,不知道学号:

  • (校区, 学号):北京的学生排在一起,方便集中查找。
  • (学号, 校区):北京的学生可能散在不同学号下面,不能像前一种目录那样直接找出一整段。

只知道学号,不知道校区时,道理反过来。

这就是常说的“最左前缀”:可以先理解成,只给一个查询条件时,这个条件最好是索引里排在最前面的那一列。

笔试时可以这样回答:

我选 A。因为 C1 和 C2 都是等值查询,不管索引是 (C1, C2) 还是 (C2, C1),都能用这两个值一起定位,所以效率基本相当。不能只看 C1 的不同值多,就认为它放前面一定更快。如果只查其中一列,索引顺序就需要重点考虑了。

记住这一句:两个值都给全了,两种顺序都能找;只给其中一个值,就要看它排在哪里。

这里按常见的 B-tree 联合索引分析。“基本相当”不表示实际耗时精确相同;有大于、小于等条件时,也需要重新分析。

参考资料:MySQL 官方文档:多列索引。文档说明联合索引可以使用全部索引列,或者从第一列开始的连续几列;以上例子用于帮助理解,本题选 A 是结合这些原理作出的判断。

1.2 第 6 题:哪种表连接方式只能用于等值连接

题目

以下哪种表连接方式只能用于等价连接?

选项连接方式
ANESTED LOOPS
BSORT MERGE JOIN
CHASH JOIN

考点

三种表连接算法的区别,以及 HASH JOIN 为什么需要等值匹配。

这里的“等价连接”通常叫“等值连接”,就是用 = 把两张表的数据对应起来。题目的术语常见于 Oracle,这里按传统连接算法及 Oracle 的语境理解。

答案

C:HASH JOIN(哈希连接)。

解析

先弄懂什么是等值连接。

假设有员工表和部门表,要查“每个员工属于哪个部门”:

SELECT e.name, d.dept_name
FROM employee e
JOIN department d
  ON e.dept_id = d.id; -- 员工的部门编号,等于部门表里的编号

例如员工小王的部门编号是 10,就找编号为 10 的部门。这里连接两张表的条件是 =,所以叫等值连接。

如果连接条件换成 e.salary > g.salary,要找的是工资更低的另一张表中的记录,这就不是等值连接。

HASH JOIN:先做一份按编号查找的字典。

可以把它理解成先把部门表整理成类似 Java HashMap 的结构:

10 → 技术部
20 → 财务部
30 → 人事部

再读取员工表:小王的部门编号是 10,就用 10 去字典里找技术部。

哈希的作用,是根据编号算出该去哪个位置查找;找到候选记录后,还要核对编号是否真的相等,因为不同编号也可能算到同一个位置。

为什么要求等值条件?

因为这种字典擅长回答“找编号等于 10 的记录”。如果问“找所有编号大于 10 的记录”,单凭 10 算出的位置,就无法知道其他更大的编号在哪里。哈希存放的位置不按编号大小排序。

所以,传统 HASH JOIN 需要等值条件来配对。这就是本题选 C 的原因。

另外两种为什么不选?

方式大白话理解为什么不选
NESTED LOOPS:嵌套循环连接拿第一张表的一条记录,去第二张表找符合条件的记录,再处理下一条查找时既可以判断相等,也可以判断大于、小于
SORT MERGE JOIN:排序合并连接先把两边的数据按连接字段排好,再利用顺序比较和配对在 Oracle 中,除了相等,也可处理部分大于、小于等条件
HASH JOIN:哈希连接先做按值查找的字典,再用另一张表的值去匹配传统算法需要等值条件,因此选它

这三种是数据库执行连接时的算法,和 SQL 里的 INNER JOIN、LEFT JOIN 不是同一种分类。前者讲“怎么查”,后者讲“哪些记录要保留”。

容易误解的一点: HASH JOIN 并不要求整条 SQL 只能出现 =。例如 ON e.dept_id = d.id AND e.salary > d.min_salary,可以先按部门编号做哈希匹配,再判断工资条件。关键是有可用于哈希匹配的等值条件。

记住:哈希像字典,按相同的值找对象;它不靠大小顺序找对象。

参考资料:Oracle 官方文档:Joins。以上是本题常见的 Oracle 考法,不宜直接当成所有数据库版本的限制。

1.3 第 5 题:防止 SQL 注入的最佳方式

题目

下列哪种是最佳防止 SQL 注入的方式?

选项内容
A对外部输入进行转义
B限制外部输入的长度
C使用存储过程
D使用预编译语句

考点

SQL 注入的原因,以及使用预编译语句和参数绑定防止注入的原理。

答案

D:使用预编译语句,并通过参数绑定传入外部输入。

解析

SQL 注入是什么?

本来用户输入的内容只是“要查询的值”,但程序直接把它拼进 SQL,结果其中的特殊内容变成了查询指令,改变了原来的查询意思。

例如,系统允许按用户名查询,程序用下面这种方式拼接 SQL。这里的 username 来自用户输入:

// 错误示例:把用户输入直接拼成 SQL 的一部分。
String sql = "SELECT id FROM users WHERE username = '" + username + "'";

正常输入 小王,拼出来是:

SELECT id FROM users WHERE username = '小王';

如果输入的是 ' OR '1'='1,拼出来就变成:

SELECT id FROM users WHERE username = '' OR '1'='1';

'1'='1' 永远成立,查询就可能把原本不该查出来的其他用户也返回。这就是输入改变了 SQL 的意思。

预编译语句怎么解决?

先把 SQL 的结构写好,需要传值的位置用 ? 留出来,再单独传入参数。

下面是 Java JDBC 的查询片段,假设 connection 是已经取得的数据库连接,username 是外部传入的用户名:

// SQL 结构固定,问号只用于接收用户名这个值,不加引号。
String sql = "SELECT id FROM users WHERE username = ?";
try (java.sql.PreparedStatement ps = connection.prepareStatement(sql)) {
    // setString:把 username 作为字符串参数,绑定到第一个问号。
    ps.setString(1, username);
    // executeQuery:按固定的 SQL 结构查询,参数只作为数据。
    try (java.sql.ResultSet rs = ps.executeQuery()) {
        while (rs.next()) {
            System.out.println(rs.getLong("id"));
        }
    }
}

这样,即使传入 ' OR '1'='1,也只是查找“用户名恰好叫这整串文字”的用户,里面的 OR 不会变成查询指令。

可以想成填表:表格格式由程序定好,用户只能填写姓名,不能把填进去的内容变成新的表格规则。

防注入的关键是“SQL 结构和参数分开”。仅仅用了 PreparedStatement,却仍把用户输入拼进 SQL 字符串,也不能解决问题。

其他选项为什么不选?

选项原因
对输入转义容易受数据库语法、字符集等影响而遗漏,不如参数绑定可靠
限制长度短输入也可能包含改变 SQL 含义的内容,长度限制不能解决根本问题
使用存储过程正确使用可以防注入,但如果内部仍拼接不可信输入执行动态 SQL,也可能有漏洞
使用预编译语句配合参数绑定,让输入作为数据处理,是本题最佳选项

参数占位符用于传入值,不能随意替代表名、列名或排序方向。确实需要动态选择这些内容时,应由程序从预先允许的选项中选择,不能直接拼接任意用户输入。

记住:用户输入只当数据,不让它变成 SQL 指令;使用占位符,再绑定参数。

参考资料:OWASP:SQL 注入防护指南。

1.4 第 11 题:只存日期时,哪种类型占用空间最小

题目

如果只需要存储日期(比如交易日),以下哪个类型占用存储空间最小?

选项类型
ANUMBER
BTIMESTAMP
CDATE
DVARCHAR2

考点

Oracle 数据类型的存储大小,以及“占空间最少”和“最适合表示日期”的区别。

答案

按 Oracle 中用 YYYYMMDD 数字表示交易日的常见考法,选 A:NUMBER。

截图没有给出具体存储格式,题意存在一些省略。这里假设同一天分别存成数字 20261020、日期值或日期字符串来比较,不能理解成 NUMBER 在任何情况下都最小。

解析

先确认:这是 Oracle 的题,不能套用 MySQL 的 DATE 大小。

VARCHAR2 是 Oracle 中常见的字符串类型。Oracle 的 DATE 不只保存年月日,还保存时分秒,固定占 7 字节;即使你只需要日期,也不会因此缩小。

拿交易日“2026 年 10 月 20 日”举例:

类型怎么表示这一天值本身大致占用空间
NUMBER数字 202610205 字节
TIMESTAMP日期时间值,还能表示小数秒官方文档列为 7 或 11 字节,取决于精度
DATE日期时间值,只使用其中的年月日固定 7 字节
VARCHAR2字符串 '20261020'在常用字符集中,8 个数字字符占 8 字节;带横线的 '2026-10-20' 则占 10 字节

表中比较值本身的存储,不包含完整行、索引等额外开销。

为什么 8 位数字用 NUMBER 只要 5 字节?

因为“数字”和“数字组成的文字”存法不同:

  • VARCHAR2 像逐字记下 2、0、2、6、1、0、2、0,在常用字符集中要用 8 字节。
  • Oracle NUMBER 会把数字压缩编码。对于 20261020,可理解成按两位一组:20、26、10、20,这 4 组加上 1 字节指数信息,共 5 字节。

这个拆分只是帮助理解 Oracle 的内部编码。NUMBER 是变长存储,不是声明成 8 位数字就一定占 8 字节;某些值还会因为末尾的零等因素占得更少。

那项目里是不是都应该用 NUMBER 存日期?

不是。题目问的是“谁更省空间”,实际设计还要看是否方便、是否容易出错。

  • NUMBER 可以存进 20261340 这种不存在的日期,需要程序额外校验。
  • DATE 有日期语义,可以直接做日期运算。比如 DATE 值加 1 是后一天;数字 20261031 加 1 却得到 20261032,不会自动变成 11 月 1 日。

所以,这道题按存储大小选 NUMBER;实际业务如果需要日期校验和日期计算,通常优先考虑日期类型。

记住:Oracle 的 DATE 固定 7 字节;8 位交易日数字用 NUMBER 通常更省,但数字不会自动遵守日历规则。

参考资料:Oracle 官方文档:数据类型和大小、Oracle 官方文档:NUMBER 的内部编码。

1.5 第 20 题:对结果集分组和汇总的子句

题目

下面哪个子句用于对结果集进行分组和汇总?

选项内容
AORDER BY
BHAVING
CWHERE
DGROUP BY

考点

区分分组、汇总、筛选和排序,尤其是 GROUP BY 与 HAVING 的区别。

答案

D:GROUP BY。

GROUP BY 负责把数据分组,再配合 COUNT()、SUM()、AVG() 等函数统计每组的数据。截图中选中的 HAVING 不是本题正确答案。

解析

用“统计各部门人数”来理解。

假设员工表 employee 里有这些记录:

name(姓名)dept(部门)
小王技术部
小李技术部
小张财务部

现在想知道每个部门有多少人:

SELECT dept, COUNT(*) AS employee_count
FROM employee
GROUP BY dept; -- 相同部门的员工分成一组,COUNT(*) 数每组有几人。

结果是下面两组,显示顺序不作保证:

deptemployee_count
技术部2
财务部1

可以理解为:先让同部门的人站在一起,再分别数人数。 GROUP BY dept 负责站队分组,COUNT(*) 负责数人数。不是 GROUP BY 自己负责求和、计数。

HAVING 是干什么的?为什么不能选它?

如果只想看“人数大于 1 的部门”,要先统计每个部门人数,再筛选统计结果:

SELECT dept, COUNT(*) AS employee_count
FROM employee
GROUP BY dept
HAVING COUNT(*) > 1; -- 分组统计后,只保留人数大于 1 的组。

这时只剩技术部,人数为 2。GROUP BY 负责分组,HAVING 负责筛选分组后的结果。

四个选项放在一起记。

子句大白话作用示例
WHERE分组前,先筛掉不符合条件的原始记录只统计在职员工
GROUP BY按某个标准分组同一个部门的员工归为一组
HAVING分组后,筛选符合条件的组只保留人数大于 1 的部门
ORDER BY给查询结果排顺序部门人数从多到少排列

假设 employee 表还有 status 字段,用 '在职' 表示在职,可以把它们连起来看:

SELECT dept, COUNT(*) AS employee_count
FROM employee
WHERE status = '在职'        -- 先挑出在职员工。
GROUP BY dept                -- 再按部门分组,并统计人数。
HAVING COUNT(*) > 1           -- 只保留在职人数大于 1 的部门。
ORDER BY employee_count DESC; -- 最后按人数从多到少显示。

这里描述的是帮助理解的逻辑处理过程,数据库实际执行时可能优化执行方式。

记住:WHERE 先挑人,GROUP BY 分队,COUNT 数人数,HAVING 挑队,ORDER BY 排顺序。

参考资料:Oracle 官方文档:SELECT 中的 GROUP BY、HAVING 和 ORDER BY。这几个子句的基本用途在 MySQL 中也适用。

1.6 第 7 题:修改序列时,哪个语句会报错

题目

下面哪个语句会报错?

选项语句
AALTER SEQUENCE seq1 MAXVALUE 90;
BALTER SEQUENCE seq1 NOMINVALUE;
CALTER SEQUENCE seq1 START WITH 1;
DALTER SEQUENCE seq1 NOCACHE;

考点

Oracle 序列的用途、修改序列的语法,以及起始值与最大值、缓存等设置的区别。

答案

C:ALTER SEQUENCE seq1 START WITH 1;。

题目通常假设 seq1 已存在、有修改权限,而且其他设置不冲突,考的是语法。不能直接用这个写法修改已有序列的起始值。

解析

序列是什么?

可以把 Oracle 的序列想成一台“自动取号机”,名字叫 seq1。假设设定从 1 开始,每次加 1,那么取号时会依次得到 1、2、3……,经常用于生成记录编号。

ALTER SEQUENCE 就是修改这台取号机的设置。

逐个选项看。

选项大白话意思判断
A:MAXVALUE 90把允许生成的最大号码设为 90语法正确,但要满足当前号码、最小值、缓存等约束
B:NOMINVALUE不再使用手动指定的最小值,改用数据库默认最小值语法正确;普通递增序列的默认最小值为 1
C:START WITH 1想直接把已有序列的起始号码改成 1这个 ALTER 写法不正确,因此选 C
D:NOCACHE不预先缓存一批号码语法正确

为什么 C 不行?

START WITH 表示“从哪个号码开始”,常见于创建序列时。已经存在的取号机,不能只靠 ALTER SEQUENCE ... START WITH 1 这句话重新设置起点。

Oracle 19c 支持通过 RESTART START WITH 表达“重新从指定号码开始”。下面仅作语法对比,未连接数据库执行:

-- 题目中的错误写法:缺少 RESTART。
ALTER SEQUENCE seq1 START WITH 1;
 
-- Oracle 19c 支持的重启写法,仍需满足序列的其他约束。
ALTER SEQUENCE seq1 RESTART START WITH 1;

因此不能笼统记成“所有 Oracle 版本都不能重置序列”,应记住:本题 C 的写法不对;支持 RESTART 的版本,要用相应的重启语法。

NOCACHE 又是什么?

可以把缓存想成提前准备一批待发的号码,以减少取号时的开销。NOCACHE 表示不采用这种预先缓存方式,不是“不生成号码”,也不能保证号码永远连续无缺口。例如业务事务回滚,也可能造成号码跳过。

A 会不会也报错?

有可能。如果序列当前号码已经超过 90,就不能再把最大值改为 90;其他参数冲突也可能失败。但截图没有给出这些状态,因此这道单选题按“哪句语法不正确”理解,选 C。

记住:MAXVALUE 改上限,NOMINVALUE 恢复默认下限,NOCACHE 关缓存;直接 ALTER 加 START WITH 不行。

参考资料:Oracle 19c 官方文档:ALTER SEQUENCE。

1.7 第 15 题:直接赋值与 SELECT INTO 的效率

题目

下面的赋值语句哪个效率更高?

选项语句
Av := 1;
BSELECT 1 INTO v FROM dual;

考点

Oracle PL/SQL 的直接赋值,以及避免为了简单赋值执行不必要的 SQL 查询。

答案

A:v := 1;,直接赋值效率更高。

假设 v 是已经声明、能够接收数字的变量。两种写法都可以把 1 放进 v,但直接赋值不需要执行一次 SQL 查询。

解析

先看这些符号是什么意思。

  • v:变量,可以理解为一个存放数据的小盒子。
  • :=:PL/SQL 的赋值符号,意思是“把右边的值放进左边变量”,作用类似 Java 的赋值 =。
  • INTO v:把查询得到的值放进变量 v。
  • dual:Oracle 提供的一张特殊的单行表,常用于查询常量或表达式,不是本题的业务数据表。

所以两句话分别是:

v := 1;                     -- 直接把数字 1 放进变量 v。
SELECT 1 INTO v FROM dual;  -- 先执行查询取得数字 1,再把查询结果放进 v。

为什么直接赋值更快?

就像你已经知道答案是 1:直接写到纸上就行,没有必要再找人问一次“1 是多少”,等对方回答后才写下来。

PL/SQL 负责执行变量赋值、循环等程序逻辑;SQL 引擎负责执行 SELECT 等数据库语句。第二种写法通常需要把查询交给 SQL 引擎处理,再把结果交回来,这个交接叫“上下文切换”,会产生额外开销。

这里的切换发生在数据库内部,不等于一定要通过网络再请求数据库一次。即使数据库优化了对 dual 的访问,也不需要为了一个已知常量引入查询。

只执行一次,差别可能不明显;放在大量重复的循环中,不必要的查询开销就可能累积。具体差多少要看环境,不能简单说固定快几倍。

什么时候才需要 SELECT INTO?

如果值已经知道,或者可以直接计算,就直接赋值。如果需要从业务表中查出数据,再用 SELECT INTO。

例如,已知 employee 表里 id 为 1001 的员工恰好有一条记录,需要读取他的工资:

-- 此处假设 v 已声明为合适的数字变量。
SELECT salary INTO v
FROM employee
WHERE id = 1001;

这次工资是多少需要查表才能知道,使用 SELECT INTO 就有实际意义。普通 SELECT INTO 要求结果恰好一行;没有记录或返回多条记录时,需要处理对应异常。

完整的直接赋值例子。

下面是 PL/SQL 匿名块,只演示变量赋值和输出,不修改业务表,也未在数据库中执行:

DECLARE
    v NUMBER; -- 声明一个存放数字的变量。
BEGIN
    v := 1; -- 直接赋值,不用 SELECT。
    DBMS_OUTPUT.PUT_LINE(v); -- 输出变量值;在客户端启用 DBMS Output 后可看到 1。
END;

记住:已知的值直接赋,表里的值才去查。

参考资料:Oracle 官方:PL/SQL 与 SQL 引擎之间的切换开销。

1.8 第 26 题:哪些语句会隐式提交事务

题目

下面哪些语句会隐式提交事务?(多选)

选项语句
ACREATE
BTRUNCATE
CUPDATE
DSELECT

考点

Oracle 中 DDL 的隐式提交,以及它与普通数据修改、客户端自动提交的区别。

答案

按 Oracle 普通表的常见考法,选 A、B:CREATE、TRUNCATE。

截图中 UPDATE 也被选中了,这一项不应选。题目讨论语句自身的提交行为,默认不把客户端的自动提交设置算进去。

解析

先理解“事务”和“提交”。

可以把事务想成一批准备一起确认的修改。修改后还没提交,通常可以用 ROLLBACK 撤销这批修改;执行 COMMIT,就是正式确认,之后不能再靠普通回滚撤销。

  • 显式提交:你自己写 COMMIT。
  • 隐式提交:你没写 COMMIT,数据库因为执行某类语句,自动进行了提交。

为什么 CREATE、TRUNCATE 会提交?

它们属于 DDL,也就是管理数据库对象的语句:CREATE 用来创建表等对象,TRUNCATE 用来清空表的数据。

在 Oracle 中,普通 DDL 执行前会提交当前事务,执行成功后还会提交 DDL 的结果。因此,它不只影响当前这一句,还可能把之前没提交的修改一起确认。

下面只是过程说明,没有在数据库中执行任何操作:

1. 用 UPDATE 修改小王的工资,还没提交。
2. 在同一个会话中,用 CREATE 创建一张普通表。
3. Oracle 在执行这条 DDL 前,先提交之前的事务。
4. 这时再执行 ROLLBACK,也不能撤销第 1 步的工资修改。

即使 DDL 后来执行失败,执行前的提交也可能已经发生。因此,不能认为“建表失败,之前的修改就一定还可以回滚”。

为什么 UPDATE 不选?

UPDATE 是修改表中记录的 DML 语句。普通情况下,它修改数据后不会自己提交,可以继续执行其他修改,再一起提交或回滚。

为什么 SELECT 不选?

普通 SELECT 只是查询,不会因此提交已有事务。即使是 SELECT ... FOR UPDATE,也不能把它理解成提交命令。

语句大白话作用是否因语句自身触发本题所说的隐式提交
CREATE创建表等数据库对象是
TRUNCATE清空普通表的数据是
UPDATE修改记录否
SELECT查询记录否

TRUNCATE 和 DELETE 都能删数据,有什么不同?

在 Oracle 普通表的场景中:

  • DELETE 是 DML,可以带 WHERE 只删除符合条件的记录;未提交时通常可以回滚。
  • TRUNCATE 是 DDL,清空整张表,不能带 WHERE;会发生隐式提交,不能靠普通 ROLLBACK 恢复清空的数据。

那为什么有时 UPDATE 后,没写 COMMIT 数据也保存了?

可能是工具或 JDBC 连接开启了自动提交。那是客户端在执行后帮你提交,和 CREATE、TRUNCATE 自身触发的隐式提交是两个概念。例如 JDBC 连接通常默认开启自动提交,不能因此在本题中选择 UPDATE。

这里按前面题目的 Oracle 语境及普通表说明;特殊临时表、其他数据库的 DDL 事务规则可能不同,不能直接套用。

记住:Oracle 普通 DDL 会提交;UPDATE 改数据,COMMIT 才确认;自动提交设置要另算。

参考资料:Oracle 官方:COMMIT 的隐式提交规则。

1.9 第 13 题:INSERT 未提交时,谁能看到新记录

题目

某会话对 T 表执行了一个 INSERT 操作,但还没有提交,下面说法正确的有:

选项内容
A该会话可以查询 T 表,但只有提交后才能查到这些新插入的记录
B其他会话对 T 表的 SELECT 操作将会被阻塞,直到该会话提交或回滚
C其他会话对 T 表的 INSERT 操作将会被阻塞,直到该会话提交或回滚
D即使未提交,该会话也可以查询到这些新插入的记录

考点

事务对自身修改的可见性、Oracle 的一致性读,以及“看不到”和“被阻塞”的区别。

答案

D。自己可以查到自己尚未提交的新记录。

按前面题目的 Oracle 语境,讨论普通 INSERT、普通 SELECT,不包括直接路径插入等特殊场景。截图选中的 A 不正确。

解析

会话是什么?

可以先把它理解为一条独立的数据库连接。假设你开了两个查询窗口,分别使用两条独立连接,就有会话 A 和会话 B。仅仅打开两个标签页不一定是两个会话,要看工具有没有共用连接。

用“添加小王”来理解。

假设 T 表原来只有小李、小张。会话 A 插入小王,但先不提交:

谁来查能不能看到小王原因
会话 A 自己查能自己可以看到自己在当前事务中做的修改
会话 B 普通查询看不到Oracle 不允许读取其他事务尚未提交的数据

会话 B 不需要一直等,它仍能查到原来已经提交的小李、小张,只是结果里没有小王。

所以,“没提交”不等于“谁都看不见”。正确理解是:自己能看,别人暂时看不到。

下面仅说明过程,没有连接数据库执行任何操作:

1. 会话 A 插入小王,不提交。
2. 会话 A 查询:能看到小王。
3. 会话 B 普通查询:正常返回,但没有小王。
4. 会话 A 提交。
5. 在 Oracle 默认 READ COMMITTED 隔离级别下,
   会话 B 在 A 提交后新发起的查询可以看到小王。

第 5 步指的是“会话 A 提交之后,会话 B 再发起查询”,不是要求会话 B 必须先提交。已经运行中的查询不会因为别人刚提交,就在中途自动刷新结果。其他隔离级别下,可见时间也可能不同。

如果会话 A 最后选择回滚,小王这条新记录就被撤销,其他会话也不会因为它曾经插入过就能查到它。

逐项看为什么。

选项判断大白话原因
A错自己不需要等提交,就能查到自己新插入的记录
B错普通 SELECT 通常不会被这个未提交的 INSERT 挡住,只是看不到新记录
C错不是整张表都不准插入;其他会话通常还能插入不冲突的记录
D对当前事务能看到自身修改

那其他会话插入时,真的不会等待吗?

有可能等待,但不是“只要往同一张表插入就必须等”。例如 T 表的 id 有唯一约束:

  • 会话 A 插入 id 为 1001 的记录,未提交。
  • 会话 B 也想插入 id 为 1001 的记录。
  • 会话 B 可能需要等 A 提交或回滚,才能确定这个编号还能不能使用。
  • A 提交后,B 通常因重复编号报错;A 回滚后,B 才可能成功。

如果 B 插入的是不冲突的另一条记录,通常不必因为 A 的普通插入而等待。因此 C 的说法太绝对。

普通 SELECT 与 SELECT ... FOR UPDATE 也要区分:后者要加锁,遇到冲突时可能等待,不能直接套用普通查询的结论。

记住:自己未提交的数据自己能看;别人普通查询不等你,但看不到这条新数据;写入冲突才可能等待。

参考资料:Oracle 官方:数据并发与一致性。

二、Java 类

2.1 第 2 题:ArrayList 初始化与扩容

题目

下面这条语句中的 list 集合扩容了几次?

ArrayList list = new ArrayList(20);

考点

区分 ArrayList 的“初始容量”“实际元素数量”和“扩容”。

答案

0 次。 这条语句只是创建集合,并把初始容量设为 20,没有发生扩容。此时实际元素数量 size() 为 0。

解析

把 ArrayList 想成一个装东西的箱子:

  • new ArrayList(20):一开始就准备好能装 20 件东西的箱子。
  • 此时还没放东西,所以实际元素数量是 0。
  • 后来东西放不下了,需要换更大的箱子,这才叫“扩容”。

一开始就准备 20 个位置,不等于扩容了 20 次,也不等于已经放了 20 个元素。

操作实际元素数量扩容次数(从创建起累计)
new ArrayList(20)00
连续添加 20 个元素200
再添加第 21 个元素211

什么时候才开始扩容?

对于本题的 new ArrayList(20),添加第 21 个元素时,才第一次扩容。

可以把它想成一开始准备了 20 个座位:

  • 添加第 1~20 个元素:还有位置,不用扩容;第 20 个放进去后刚好坐满。
  • 添加第 21 个元素:已经没有空位,先扩容,再放入新元素。
  • 常见 JDK 实现会将容量从 20 扩到 30,因此继续添加到第 30 个元素都不用再次扩容,添加第 31 个时才第二次扩容。

为什么是 20 扩到 30?

因为常见 JDK 实现中,ArrayList 在正常逐个添加元素时,会优先按“原容量再增加一半”来扩容,也就是通常说的 1.5 倍扩容。

新容量 = 原容量 + 原容量的一半
       = 20 + 10
       = 30

因此,添加第 21 个元素时,虽然只差 1 个位置,ArrayList 通常会一次多准备一些,扩到 30。添加完成后,实际元素数量是 21,容量是 30,还空着 9 个位置。

接下来逐个添加,容量变化是:

触发时机计算过程新容量
添加第 21 个元素20 + 1030
添加第 31 个元素30 + 1545
添加第 46 个元素45 + 2267

45 的一半是 22.5,这里按整数计算舍去小数,所以增加 22,得到 67。

为什么不只从 20 扩到 21?

因为 ArrayList 底层用数组存东西,原数组的长度不能直接变大。扩容时,需要新建一个更大的数组,再把原来存的元素引用复制过去。

如果每次只增加 1 个位置,后面每添加一个元素都可能要重新建数组、复制原来的内容。一次多留些空位,就能减少这种搬家的次数;但也会暂时多占一些空间。

源码里的“一半”怎么看?

OpenJDK 21 的扩容代码用 oldCapacity >> 1 表示优先增加的容量。对这里的正整数来说,>> 1 可以理解为“除以 2,舍去小数”。所以 20 的一半是 10,新容量就是 30。

如果一次批量加入很多元素,1.5 倍还装不下,就要至少扩到能装下所需元素的容量。因此,1.5 倍是通常的增长方式,不是任何情况下都固定乘 1.5。它也是具体 JDK 的实现规则,不是 Java API 对所有实现的强制要求。

源码参考:OpenJDK 21:ArrayList 的 grow 方法。

记住:刚好装满不扩容,再放一个才扩容。

上表及上述过程假设只依次添加元素,没有其他调整容量的操作。

记住:初始化是准备箱子,扩容是箱子不够用后换大箱子。

参考资料:Java 官方文档:ArrayList。带容量参数的构造方法创建的是空集合,参数指定初始容量。

2.2 第 24 题:哪些是 Thread 类的方法

题目

下面哪些是 Thread 类的方法?(多选)

选项方法
AgetPriority()
Brun()
Cstart()
Dexit()

考点

Thread 类的常用公开方法,以及 run() 和 start() 的区别。

答案

A、B、C:getPriority()、run()、start()。

exit() 不是供业务代码调用的 Thread 公开方法;常见的 System.exit(0) 属于 System 类。

解析

先把这三个方法的作用分清楚。

方法大白话作用
getPriority()查看这个线程的优先级数值
run()写线程要执行的任务内容
start()启动线程,让它去执行任务

线程可以理解为程序里的一名“干活的人”。run() 描述要干什么,start() 才是让另一名干活的人开始工作。

直接调用 run(),为什么不算启动线程?

对于下面这种通过普通构造方法创建的线程,直接调用 run(),只是当前线程执行了一次普通方法;调用 start(),才会启动新线程,由新线程执行任务。

把下面代码保存为 ThreadDemo.java,可用 javac ThreadDemo.java 编译,再用 java ThreadDemo 运行:

public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        // 创建线程并指定任务;此时尚未启动线程。
        Thread worker = new Thread(() -> {
            // currentThread:获取当前真正执行这段代码的线程。
            // getName:查看线程名字,帮助判断是谁在执行。
            System.out.println("执行任务的线程:" + Thread.currentThread().getName());
        }, "worker");
 
        // getPriority:读取线程优先级,不会启动线程。
        System.out.println("优先级:" + worker.getPriority());
 
        // 直接调用普通方法,执行任务的仍是 main 线程。
        worker.run();
 
        // 启动 worker 线程,由 worker 执行任务。
        worker.start();
 
        // join:让 main 等待 worker 完成任务。
        worker.join();
    }
}

直接在普通命令行环境运行时,任务部分依次输出:

执行任务的线程:main
执行任务的线程:worker

第一行说明直接调用 run() 没有启动新线程;第二行说明 start() 启动的新线程执行了任务。同一个线程对象只能启动一次,再次调用 start() 会抛出 IllegalThreadStateException,即使它已经执行完也不能重新启动。

getPriority() 是不是优先级越高,就一定先执行?

不是。它只是读取优先级,不能靠优先级保证线程的执行先后顺序。普通平台线程的优先级范围是 1~10,通常 main 线程默认是 5,新建线程会继承创建它的线程的优先级。

exit() 为什么不选?

你可能见过 System.exit(0),但它属于 System 类,表示终止整个 JVM,不是只结束某一个线程。普通线程的任务执行完,线程自然结束,不需要调用 exit()。

个别 JDK 版本的 Thread 源码中存在私有的 exit() 清理方法,但外部不能调用。此类笔试通常考公开可用的方法,因此不选 D;如果题目严格追问“源码里是否声明过”,则应区分 JDK 版本和访问权限。

记住:run 写任务,start 启线程,getPriority 看优先级;System.exit 退出整个程序。

参考资料:Java 官方文档:Thread。

2.3 第 1 题:父子类构造方法的执行顺序

题目

下面代码的输出结果是什么?(将截图代码整理排版,字符串使用 Java 的英文双引号。)

class Base {
    Base() {
        System.out.print("Base");
    }
}
 
public class Alpha extends Base {
    public static void main(String[] args) {
        new Alpha();
        new Base();
    }
}

考点

默认构造方法、super() 对父类构造方法的调用,以及 print() 不自动换行。

答案

BaseBase

两个 Base 连在一起,中间没有空格,也没有自动换行。

解析

先看 Base() 是什么。

Base() 与类名相同,并且没有返回类型,因此它是构造方法。创建对象时,会调用构造方法完成初始化。本题每执行一次 Base(),就打印一次 Base。

第一步:执行 new Alpha()。

Alpha extends Base 表示 Alpha 是子类,Base 是父类。Alpha 没有写任何构造方法,编译器会自动补一个默认的无参构造方法,在本题中可以理解为:

// 这是解释编译器默认行为的等价写法,不是需要额外添加的代码。
public Alpha() {
    super(); // 调用父类 Base 的无参构造方法。
}

所以,虽然写的是 new Alpha(),仍然会调用父类的 Base(),打印第一次 Base。随后 Alpha 的构造方法没有其他内容,也就没有别的输出。

可以理解为:要初始化子类对象,得先完成它的父类部分的初始化。 这不表示另外创建了一个独立的 Base 对象;这里创建的仍是一个 Alpha 对象。

第二步:执行 new Base()。

这次直接创建 Base 对象,调用 Base(),打印第二次 Base。

main 中的执行顺序为什么打印输出
new Alpha()默认构造方法通过 super() 调用 Base()Base
new Base()直接调用 Base()Base

为什么不是分两行?

因为题目使用 System.out.print("Base"),只打印内容,不自动换行。如果改为 println("Base"),才会每次打印后换行。

怎么自己验证?

将题目完整代码保存为 Alpha.java(文件名与公开类 Alpha 一致),用 javac Alpha.java 编译,再用 java Alpha 运行,输出就是 BaseBase。

记住:本题 new 子类也会调用父类构造方法;两次调用 Base(),就打印两次 Base。

补充边界:只有类里完全没声明构造方法,编译器才自动提供默认构造方法。如果父类只有带参数的构造方法,子类就不能直接依赖默认的 super(),需要明确传入参数,否则会编译失败。

参考资料:Java 语言规范:默认构造方法。

2.4 第 22 题:ArrayList、LinkedList 和 HashMap 的特点

题目

Java 中的集合类包括 ArrayList、LinkedList、HashMap 等类,下列关于集合类描述正确的是哪些?(多选)

选项内容
AArrayList 和 LinkedList 均实现了 List 接口
BArrayList 的访问速度比 LinkedList 快
C添加和删除元素时,ArrayList 的表现更佳
DHashMap 实现 Map 接口,它允许任何类型的键和值对象,并允许将 null 用作键或值

考点

List 与 Map 的区别、数组与链表的查找和增删特点,以及 HashMap 对 null 的支持。

答案

按常见笔试题意,选 A、B、D。

B 中的“访问”按“根据下标取元素”理解;不是说任何访问方式下 ArrayList 都一定更快。D 中的“任何类型”指可使用不同类型的对象,但实际添加时仍要符合声明的泛型类型。

解析

A 为什么对?List 可以理解为一份按位置排列的清单。

ArrayList 和 LinkedList 都实现 List 接口,都能按顺序存放元素、允许重复,并支持 get(下标)。

例如清单为 [小王, 小李, 小张],get(0) 取小王,get(1) 取小李。下标从 0 开始。

两者都能做这些事,但底层存法不同:

  • ArrayList:底层是数组,像一排有位置编号的格子。
  • LinkedList:底层是双向链表,像一串前后相连的节点,每个节点记住前一个、后一个节点在哪里。

B 为什么通常判对?按下标找,数组能直接定位,链表需要沿着节点找。

假设要取第 500 个元素,也就是 get(499):

  • ArrayList 根据下标直接找到对应数组位置,不需要从第一个元素数过去。
  • LinkedList 从头或尾较近的一端出发,沿着节点逐个找到目标位置。

所以大量按下标取元素时,ArrayList 通常更合适。技术上,ArrayList 的 get(index) 是 O(1),LinkedList 的一般下标访问是 O(n):前者的定位工作量不随元素数量增长,后者最坏需要走过与元素数量成比例的节点。

但如果只是取第一个、最后一个元素,LinkedList 也能直接找到;用迭代器顺序遍历时,两者都只需依次访问元素。因此不能把 B 扩展成“所有访问方式都必然更快”。

C 为什么不选?增删快不快,要看在哪里操作、是否已经找到位置。

假设有 [小王, 小李, 小张],现在要在最前面插入小赵:

  • ArrayList:要把原来的元素往后挪,给小赵腾出第一个位置。
  • LinkedList:新建小赵节点,接到原来的头节点前面,不需要把其他元素整体搬动。

但也不能反过来背成“LinkedList 所有增删都快”。如果要在中间某个下标插入,LinkedList 也要先花时间找到那里。

操作场景ArrayListLinkedList
按下标取中间元素可直接定位需要沿节点查找
在开头插入或删除需要移动后面的元素调整头部节点连接即可
在末尾添加通常很高效,空间不足时需要扩容通过尾节点添加,通常也很高效
按中间下标插入或删除定位快,但可能移动后面的元素先查找位置,再修改节点连接
迭代器已定位到中间位置后增删可能需要移动后面的元素可直接修改附近节点的连接

因此,C 这种没有说明场景、直接说 ArrayList 增删表现更佳的说法不能成立。实际运行速度还会受内存布局、对象分配等影响,不能只凭一个口诀判断。

D 为什么对?HashMap 是“根据键查值”的字典。

比如把学号作为键、姓名作为值:

1001 → 小王
1002 → 小李

HashMap 实现 Map 接口,存的是一对一对的“键和值”。它允许一个 null 键,也允许多个键对应的值为 null。

下面是可直接运行的例子,保存为 MapDemo.java,用 javac MapDemo.java 编译,再用 java MapDemo 运行:

import java.util.HashMap;
import java.util.Map;
 
public class MapDemo {
    public static void main(String[] args) {
        // 键声明为 Integer,值声明为 String,因此不能随意放入不匹配的类型。
        Map<Integer, String> students = new HashMap<>();
        students.put(1001, "小王");
        students.put(null, "未分配学号"); // 允许 null 作为键。
        students.put(1002, null); // 允许 null 作为值。
        students.put(1003, null); // 不同的键可以都对应 null 值。
        students.put(null, "待确认"); // null 键已经存在,替换它对应的值,不是新增第二个 null 键。
 
        System.out.println(students.get(1001)); // 小王
        System.out.println(students.get(null)); // 待确认
        System.out.println(students.size()); // 4
    }
}

HashMap 的键不能重复:再次放入相同的键,会替换原来的值。它不保证遍历时按插入顺序排列。

这里“允许任何类型的键和值对象”不表示可以无视泛型。例如上面的 Map<Integer, String>,键应是 Integer 或 null,值应是 String 或 null。基本类型不能直接写成泛型参数,要用 Integer 等包装类型;自定义对象作为键时,还应正确实现 equals() 和 hashCode()。

记住:List 按位置放,Map 按键找;ArrayList 按下标取值快,增删要看场景;HashMap 一个 null 键,多个 null 值。

参考资料:Java 官方文档:ArrayList、LinkedList、HashMap。

2.5 第 21 题:哪些集合是线程安全的

题目

下列哪些集合是线程安全的?(多选)

选项集合
AConcurrentHashMap(原图写作 ConcurrentHashmap)
BVector
CArrayList
DHashtable

考点

常见集合是否支持多线程安全访问,以及“单次操作安全”与“多步业务操作安全”的区别。

答案

A、B、D:ConcurrentHashMap、Vector、Hashtable。

普通 ArrayList 没有内置同步保护,不能直接让多个线程同时修改同一个实例。

解析

线程安全是什么意思?

想象两名工作人员同时往同一份名单里加名字。如果没有协调,两人可能同时认为“下一个空位是第 5 个”,结果写入互相干扰,导致数据丢失或状态错误。

线程安全集合在内部提供协调机制,让它支持的并发操作按规定正确完成。这里讨论的是多个线程操作同一个集合对象;每个线程各用自己的 ArrayList,不共享,就不是这种冲突场景。

逐个看选项。

集合线程安全?大白话解释
ConcurrentHashMap是专门支持多线程同时访问的 Map,读取通常不加锁,更新也提供并发保护
Vector是与 ArrayList 一样基于可变长数组,主要访问和修改方法通过同步保护
ArrayList否普通版本没有内置的同步保护,共享修改需要额外协调
Hashtable是较早的 Map 实现,主要访问和修改方法通过同步保护

可以把同步保护想成“操作某部分数据时先拿到钥匙”,避免其他线程同时破坏这一步操作。不过 ConcurrentHashMap 的实现更复杂,不是所有操作都锁住整张表,不能简单等同于一把大锁。

最容易漏选的是 Hashtable。

HashMap 和 Hashtable 名字相近,但特点不同:

特点HashMapHashtableConcurrentHashMap
内置线程安全支持无有有
是否允许 null 键或 null 值允许都不允许都不允许

线程安全,是否代表所有写法都安全?

不是。单次调用安全,不代表拆成多次调用的业务逻辑会自动变成一个整体。

例如,两个人都想“名单里没有小王,就加一个”:

// 假设 names 是多个线程共享的 Vector<String>。
// contains 和 add 各自有同步保护,但这两步合起来不是一次不可分割的操作。
if (!names.contains("小王")) {
    names.add("小王");
}

两个线程可能都先检查出“没有小王”,然后各添加一次,最终出现两个小王。Vector 没有被破坏,但“不重复添加”的业务要求没有得到保证。

对于 Vector,上面的检查和添加应在同一个同步范围内完成,而且其他访问也要遵守相同的协调规则:

// 假设 names 是已经创建的共享 Vector<String>。
synchronized (names) {
    // 检查和添加使用同一把锁,避免其他线程插进这两步之间。
    if (!names.contains("小王")) {
        names.add("小王");
    }
}

对于 ConcurrentHashMap,“不存在才放入”可以直接用它提供的原子方法 putIfAbsent,不用拆成先检查、再 put 两次调用。例如下面是可独立运行的单线程演示,展示该方法的行为:

import java.util.concurrent.ConcurrentHashMap;
 
public class ConcurrentMapDemo {
    public static void main(String[] args) {
        ConcurrentHashMap<String, String> users = new ConcurrentHashMap<>();
        // putIfAbsent:只有该键不存在时才放入,检查和放入作为一次原子操作完成。
        users.putIfAbsent("1001", "小王");
        users.putIfAbsent("1001", "小李"); // 键已存在,不替换原来的小王。
        System.out.println(users.get("1001")); // 输出:小王。
    }
}

保存为 ConcurrentMapDemo.java,用 javac ConcurrentMapDemo.java 编译,再用 java ConcurrentMapDemo 运行。示例只是帮助理解方法效果,其并发保证来自 API 约定,不是靠单线程示例验证的。

此外,集合线程安全,也不表示放进去的可变对象会自动线程安全;遍历、跨多个键的操作等场景仍需按各自 API 的规则处理。

记住:ConcurrentHashMap、Vector、Hashtable 选上;ArrayList 不选。单步安全,不代表多步业务自动安全。

参考资料:Java 官方:ConcurrentHashMap、Vector、Hashtable、ArrayList。

2.6 第 23 题:String、Integer 的 == 比较

题目

给定以下代码,下列选项中结果为 true 的有哪些?(多选)

String a = "aaaa";
String b = new String("aaaa");
Integer c = 100;
Integer d = 300;
Integer e = 100;
Integer f = 300;
选项表达式
Ab == "aaaa"
Bc == e
Cd == f
Da == "aaaa"

考点

对象的 == 比较、字符串常量池、Integer 自动装箱与缓存,以及 equals() 的用途。

答案

按常见默认 Integer 缓存范围,选 B、D。

表达式结果原因
b == "aaaa"falseb 指向 new 出来的新对象,与字符串字面量对应的对象不是同一个
c == etrue100 在 Integer 保证缓存的范围内,两次装箱复用同一对象
d == f通常为 false300 超出默认缓存范围,常见默认环境下两次装箱得到不同对象
a == "aaaa"truea 与相同字符串字面量指向常量池中的同一对象

严格来说,300 是否缓存受实现和配置影响;若运行环境扩大了 Integer 缓存范围,C 也可能为 true。因此笔试通常默认未调整缓存,按 B、D 作答。

解析

先记住:对象用 ==,问的是“是不是同一个对象”。

两本书内容完全一样,也不代表它们是同一本书。对象比较也是如此:

  • ==:是不是同一个对象?
  • 对于 String、Integer,equals():里面的文字或数字是不是相同?

这里 String 和 Integer 都是对象类型,所以不能只看内容相同,就判断 == 是 true。普通 int 是基本类型,两个 int 用 == 才是直接比较数值。

先看字符串 a 和 b。

String a = "aaaa" 直接使用字符串字面量。Java 会让相同的字符串字面量共享字符串常量池里的对象。

new String("aaaa") 则明确创建一个新的 String 对象。它的文字也是 aaaa,但对象是新的。

 a ──────────────→ 常量池中的 String 对象:aaaa
 "aaaa" ─────────→ 同一个常量池对象
 
 b ──────────────→ new 创建的另一个 String 对象:aaaa

因此:

a == "aaaa";      // true:同一个对象。
b == "aaaa";      // false:内容相同,对象不同。
a.equals(b);      // true:String.equals 比较文字内容。

上面是表达式讲解,实际验证时可放进 System.out.println(...) 打印,不是可直接独立执行的 Java 语句。

再看 Integer:它为什么有时会复用对象?

Integer c = 100 右边是基本类型数字,左边是 Integer 对象。Java 会自动把数字包装成 Integer,这叫“自动装箱”,通常通过 Integer.valueOf(100) 完成。

为避免常用的小整数反复创建对象,Integer 缓存了 -128~127 的对象,可以理解为预先准备好一排“公用数字盒子”。

  • c 要 100,拿到缓存里的 100 对象。
  • e 也要 100,拿到同一个缓存对象。
  • 所以 c == e 是 true。

300 不在默认的这排盒子里。常见默认环境下,d 和 f 分别得到一个装着 300 的对象,数值一样,但不是同一个对象,所以 d == f 为 false。

注意:这里 c == e 两边都是 Integer,不会为了比较而自动拆成 int。若写 c == 100,另一边是 int,则会将 c 拆成数值比较;如果 c 为 null,这种拆箱会抛出空指针异常。

真正想比较内容时怎么写?

String 和 Integer 都已经实现按内容比较的 equals。若还要兼顾变量可能为 null,可以使用 java.util.Objects.equals(x, y)。

下面是完整验证程序,保存为 EqualityDemo.java,用 javac EqualityDemo.java 编译,再用 java EqualityDemo 运行:

import java.util.Objects;
 
public class EqualityDemo {
    public static void main(String[] args) {
        String a = "aaaa";
        String b = new String("aaaa");
        Integer c = 100;
        Integer d = 300;
        Integer e = 100;
        Integer f = 300;
 
        // == 比较这些引用是否指向同一个对象。
        System.out.println(b == "aaaa"); // false。
        System.out.println(c == e);      // true。
        System.out.println(d == f);      // 常见默认环境下为 false。
        System.out.println(a == "aaaa"); // true。
 
        // Objects.equals:兼顾 null,并调用对象的 equals 比较内容。
        System.out.println(Objects.equals(a, b)); // true,文字相同。
        System.out.println(Objects.equals(d, f)); // true,数值相同。
    }
}

不要把“equals 比较内容”套用到所有自定义类:自定义类如果没有重写 equals,默认仍按对象身份比较。

记住:对象 == 看是不是同一个;字符串字面量会复用,小整数装箱有缓存;比较 String、Integer 的内容用 equals。

参考资料:Java 语言规范:自动装箱及对象复用范围、Java 官方:Integer.valueOf。