202509|技术日志

0901

SQL索引失效的情况

面试的时候经常会考察SQL的索引,而SQL索引失效的情况就是在业务场景中使用SQL索引时经常会遇到的问题

常见的索引失效,也就是我们通常说的查询未命中索引,这样的情况有:

  1. 复合索引是遵循最左匹配原则,如果索引列顺序错了,会导致索引无法命中
  2. MySQL 分析本次查询最终查询出来的数据有900条,整张表有1000条数据,查询索引的成本比全表扫描来得高,因此会直接扫描全表不会命中索引

分析查询是否命中索引

可以使用 MySQL 自带的 EXPLAIN 关键字

工厂方法模式

已整理成单独文章笔记

0902

CPU占用过高排查

top显示进程CPU利用率,找到最高的那个进程,定位PID

之后通过 jstack 命令导出这个进程的堆栈信息以便定位是哪一段代码

1
2
#将指定PID的堆栈信息导出到当前目录下的 stack.log 文件中
jstack <PID> > stack.log

同时我们查看这个进程中哪些线程占用过高,获得他们的线程ID

1
top -Hp <PID>

有了线程ID,就可以去 stack.log 中查找,但是堆栈日志文件中是十六进制,因此需要先转换一下

1
printf "%x\n" <线程ID>
1
2
#查看堆栈日志中30行这个线程相关方法
grep -A 30 'nid=0x<十六进制线程ID>' stack.log

这样就可以定位具体是哪里出了问题

策略模式

笔记回顾整理

0903

Fail Fast

我们在开发中有的时候会遇到这样的错误

常见错误1: 迭代中修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class FailFastDemo {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("A");
list.add("B");
list.add("C");

// 1. 获取迭代器(此时 expectedModCount = modCount = 3)
Iterator<String> iterator = list.iterator();

// 2. 遍历集合
while (iterator.hasNext()) {
String element = iterator.next(); // 每次 next() 都会检查 modCount
System.out.println("遍历元素:" + element);

// 3. 非迭代器方法修改集合(直接调用 list.remove())
if ("B".equals(element)) {
list.remove(element); // modCount 自增为 4
}
}
}
}

常见错误2: 多线程修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import java.util.ArrayList;
import java.util.List;

public class FailFastMultiThreadDemo {
private static final List<String> list = new ArrayList<>();

static {
list.add("A");
list.add("B");
list.add("C");
}

public static void main(String[] args) {
// 线程1:遍历集合
new Thread(() -> {
for (String element : list) { // 增强 for 循环底层依赖 Iterator
System.out.println("线程1遍历:" + element);
try {
Thread.sleep(100); // 休眠,给线程2修改时间
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();

// 线程2:修改集合
new Thread(() -> {
try {
Thread.sleep(200); // 等待线程1开始遍历
} catch (InterruptedException e) {
e.printStackTrace();
}
list.add("D"); // 修改集合,modCount 自增
System.out.println("线程2添加元素:D");
}).start();
}
}

modCount

Fail Fast 是软件编程的一种设计思想

是指当检测到错误发生的时候,立刻执行拒绝策略

我们平时写接口的时候会优先处理参数,当发现参数非法的时候立刻返回错误响应,其实也是这种思想的落地

在 JDK的 ArrayList 中就有 Fail Fast 思想的落地实现

ArrayList 继承自 AbstractList,在 AbstractList 中定义了 modCount 变量,用于记录集合被修改的次数:

1
2
// AbstractList.java(JDK8)
protected transient int modCount = 0;

每当集合发生结构性修改(添加、删除元素,或调整容量导致底层数组替换)时,modCount 会自增。

例如 ArrayListadd() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ArrayList.java
public boolean add(E e) {
ensureCapacityInternal(size + 1); // 确保容量,可能触发数组扩容(修改操作)
elementData[size++] = e;
return true;
}

private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}

private void ensureExplicitCapacity(int minCapacity) {
modCount++; // 结构性修改,modCount 自增
// ... 后续扩容逻辑
}

而在 ArrayList 的内部迭代器中,初始化的时候会拷贝一份当前的 modCount

1
2
3
4
5
6
7
8
// ArrayList.java 中的内部类 Itr(实现 Iterator 接口)
private class Itr implements Iterator<E> {
int cursor; // 下一个要返回的元素索引
int lastRet = -1; // 最后一个返回的元素索引,-1 表示没有
int expectedModCount = modCount; // 初始化时记录当前 modCount

// ... 其他方法
}

在每一次遍历的时候会优先检测是否在迭代中非法调用 remove() 导致 modCount 被修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Itr 的核心检测方法
final void checkForComodification() {
if (modCount != expectedModCount) // 核心判断:实际修改次数 vs 预期修改次数
throw new ConcurrentModificationException(); // 不一致则抛出异常
}

// 迭代器的 next() 方法
public E next() {
checkForComodification(); // 先检测
int i = cursor;
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}

// 迭代器的 remove() 方法
public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification(); // 先检测

try {
ArrayList.this.remove(lastRet); // 调用集合的 remove()
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount; // 同步 expectedModCount(关键!)
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}

若通过迭代器自身的 remove() 方法修改集合,会同步更新 expectedModCount = modCount,避免抛出异常(这是唯一允许的修改方式)

而当通过集合自身的方法(如 add()remove())修改集合时,只会更新 modCount,但不会同步更新迭代器的 expectedModCount,导致二者不一致

1
2
3
4
5
6
7
8
// ArrayList 自身的 remove() 方法(非迭代器)
public E remove(int index) {
rangeCheck(index);
modCount++; // 仅更新 modCount,不影响迭代器的 expectedModCount
E oldValue = elementData(index);
// ... 后续删除逻辑
return oldValue;
}

因此,若在遍历期间调用 list.remove()(而非 iterator.remove()),modCount 会增加,而 expectedModCount 不变,下次调用 next() 时就会抛出异常

0904

Fail Safe

Fail-Safe 机制是一种以尽可能保证系统正常运行为目标的设计方式,即使在遇到错误或异常情况下,系统仍能继续工作,避免崩溃或数据损坏

它的核心原则是:宁可以退为进,也要保证系统的健壮性和稳定性

在 JDK 中,Fail Safe 的实现可以参考 CopyOnWriteArrayList

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock(); // 加锁,保证多线程写操作的原子性
try {
Object[] elements = getArray(); // 获取原数组
int len = elements.length;
// 复制原数组到新数组(新数组长度+1)
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e; // 添加新元素到新数组
setArray(newElements); // 用新数组替换原数组
return true;
} finally {
lock.unlock(); // 释放锁
}
}

0908

HashMap 和 HashTable

HashMap HashTable
线程安全 不安全 效率高 安全(底层synchronized)效率低
容量 初始16,始终保证2的整次幂 初始11,扩容2n+1
null key 支持,单只能有一个 一个都不允许存在
底层数据结构 数组+链表,若相同数组位置中的链表长度大于8(并且数组长度大于64,转为红黑树,否则优先进行数组的扩容操作) 数组+链表(无转换优化机制)

HashSet 如何检查重复

当我们在 HashSet 中调用 add,实际上也只是底层调用了 HashMap 的put

1
2
3
4
5
6
7
8
public class HashSet     
extends AbstractSet<E>
implements Set<E>, Cloneable, java.io.Serializable
{
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
}

而 Map 的 put 方法,底层调用的 putVal 方法返回值的定义是:元素不存在返回 null,元素已存在返回该key原先的元素

因此我们程序多次调用同一个 set 对相同的 key 进行 add 操作,都是对底层的 map 进行了 put 覆盖操作,只是 HashSet 封装了一层,通过 HashMap 返回的是否存在元素,来间接返回布尔值

0909

为什么n小于64的时候优先扩容

因为对数组扩容是在数据量不大的时候更为高效的方式,数组扩容可以显著解决哈希冲突的问题

如果数组长度不大,当链表长度超过8的时候直接就转红黑树,由于红黑树本身维持自平衡需要自旋,本身也有性能开销,过早引入反而会提升复杂度影响 HashMap 的性能

自定义对象作为HashMap key时的最佳实践

重写equals和hashCode

当采用自定义对象作为 HashMap 的 key 时,需要保证该对象重写了 hashCode() 以及 equals()

这是由于 HashMap 在底层维护数据时的逻辑结构决定的

HashMap 底层首先会根据 key 对象的 hashCode() 计算出哈希值,这个值决定了数组中桶(bucket)的位置。当多个 key 的哈希值相同时(哈希冲突),HashMap 会通过 equals() 逐一比较桶中的对象,找到真正相等的 key。若 equals 实现不当,会导致无法识别相同的 key

保证参与 hash 值计算的字段不可变性

由于该对象作为 HashMap 的 key,参与该对象 hashCode 方法的字段需要严格保证不可变性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class MutableKey {
private String value; // 可修改的字段

public void setValue(String value) { // 提供修改方法,风险!
this.value = value;
}

@Override
public int hashCode() { return Objects.hash(value); }

@Override
public boolean equals(Object o) { /* 依赖value判断 */ }
}

// 测试:修改key后无法找到value
public static void main(String[] args) {
HashMap<MutableKey, String> map = new HashMap<>();
MutableKey key = new MutableKey();
key.setValue("old");
map.put(key, "value");

key.setValue("new"); // 修改key的字段,导致hashCode变化
System.out.println(map.get(key)); // 输出:null(找不到值)
}

正确的做法应该是在代码层面严格保证参与 hashCode 方法的字段在初始化后不可变

具体的实现思路有:

  • 使用 final 修饰:强推,语言自带关键字约束0910
  • 不提供字段的 setter 方法
  • 如果外部真的需要修改对象某个参与 hash 值计算字段的状态,返回新的实例(参考 String 类的实现,replace 方法看似修改实际上是创建了新的字符串,为的就是保证不可变性,因为 String 常常用作 HashMap 的 key)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ImmutableKey {

private final String value;

public ImmutableKey(String value) {
this.value = value;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;

ImmutableKey that = (ImmutableKey) o;
return value.equals(that.value);
}

@Override
public int hashCode() {
return Objects.hashCode(value);
}
}

多线程交替打印 0-100

整理成博客

0910

Builder

整理成博客

几种Factory模式对比

0911

OOM排查

0912

JDBC 动态参数最佳实践

问题分析

今天遇到一个问题,在调用JDBC传入代码中SQL参数的时候,参数SQL使用了类似如下的语法:

1
SELECT * FROM T_TABLE WHERE c_column IN (?)

代码中采用 PreparedStatement 来执行 SQL 参数,此时针对占位符的参数传入的是 list 的字符串形式 'foo','bar'.

从主观理解上来说,我们希望这一段SQL可以去查询 c_column 这一个字段值为 foo/bar 的所有记录。

但是这条 SQL 执行完成后没有任何结果,奇怪的是也没有任何的报错。

其实这是由于 PreparedStatement 的特性造成的。

我们都知道 PreparedStatement 支持防止 SQL 注入,因此引入占位符机制。上述我们的 SQL,看似参数是传入了两个元素,通过 IN 来实现 or 的查询效果。但是在 PreparedStatement 的作用下,实际上 MySQL 会将其解析为一个完整的元素,因此 IN 的括号中只算做了一个元素。相当于去表里寻找 code = ‘foo,bar’ 的记录,那当然是没有的,自然也没有任何结果,也不会有任何的报错。

网络上常见的一种实现方式是采用动态拼接 ? 占位符的方式进行实现。

但是实际上这样的实现方式并不合理。

要理解为什么不能这么做以及应该如何做,我们需要了解 PreparedStatement 和 Statement 语句的区别。

PreparedStatement和Statement

在 JDBC 中,PreparedStatement 和普通 Statement 都是用于执行 SQL 语句的接口,但核心区别在于:

  1. 预编译机制PreparedStatement 会对 SQL 语句进行预编译,而普通 Statement 每次不会。
  2. SQL 注入防护PreparedStatement 通过参数绑定(? 占位符)避免 SQL 注入,而普通 Statement 直接拼接字符串有注入风险。
  3. 性能:重复执行相同结构的 SQL 时,PreparedStatement 性能更优(预编译只需一次)。

我们重点关注预编译机制。预编译语句在数据库侧是需要通过缓存进行维护的。我们在服务代码中通过 JDBC 调用创建了 PreparedStatement 对象,此时数据库会预先解析 SQL 结构、生成执行计划,之后只需传入参数即可重复执行,无需重新解析。这样的场景十分适合 “相同结构的SQL“,也就是那些参数位置和数量都是在程序运行期间确定的 SQL.

而对于上述问题中的 SQL,我们可能传入需要 IN 查询的 list,某一个请求是3个长度,也有可能是100个长度。如果采用根据数组长度动态拼接占位符的策略,在实际执行 PreparedStatement 的时候,对应的就是两个完全不一样的 SQL,但数据库侧都需要进行维护。

此时预编译的效果已经失效,因为前一次根据 list 动态拼接占位符生成SQL的预编译缓存,不一定就会在下一次 list 参数的到来时进行命中。同时由于数据库侧还需要不断维护这些缓存数据,不但没有利用上预编译的性能优化,反倒增大了系统的开销。数据库连接池对 PreparedStatement 数量有上限,大量动态 IN 子句会耗尽这个上限,甚至导致内存溢出(OOM)。

不要简单地认为在代码中显式调用 close() 释放了 PreparedStatement 对象就可以销毁这些缓存。数据库仍可能大量维护预编译语句的缓存,原因与数据库的 预编译语句缓存机制 有关,而非我们作为应用层的服务中对象的生命周期。

此时,直接使用 Statement 动态根据 list 参数拼接条件更为妥当。无论 IN 子句参数数量如何变化,都只需生成一次 SQL 并执行,不会产生大量冗余的预编译对象。

总结

对于结构固定相同的SQL,在程序运行期间能够确定参数位置和数量的SQL,使用 PreparedStatement 执行,充分利用预编译机制

对于在程序运行期间可能存在动态参数拼接导致结构改变的SQL,采用 Statement 执行,避免预编译缓存爆炸导致的性能问题。

0916

try-with-resource

确保在 try 语句块结束时自动关闭资源,从而避免资源泄漏

但是要求使用的资源类实现AutoClosable接口,JVM底层会在try块代码执行完成后调用实现类重写的close()

相比于普通的 try-catch 而言更好用:

  • 更简洁,当申请多个系统资源都需要catch的时候,传统方法会出现catch金字塔现象
  • 能够有效解决某些资源在catch中进行关闭但是失败导致原先try中抛出的异常被覆盖的问题

下面直接以代码举例

写法更简洁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void copyTraditional(String src, String desc) throws IOException {
FileInputStream inputStream = new FileInputStream(src);
try {
FileOutputStream outputStream = new FileOutputStream(desc);
byte[] bytes = new byte[1024];
int n;
//还需要考虑释放另一个资源,因此又套一层 try-catch
try {
while ((n = inputStream.read(bytes)) != -1) {
outputStream.write(bytes, 0 ,n);
}
} finally {
outputStream.close();
}
} finally {
inputStream.close();
}
}

void copyTryWithResource(String src, String desc) throws IOException {
try (FileInputStream in = new FileInputStream(src);
FileOutputStream out = new FileOutputStream(desc)) {
byte[] bytes = new byte[1024];
int n;
while ((n = in.read(bytes)) != -1) {
out.write(bytes, 0 ,n);
}
}
}

202509|技术日志
http://example.com/2025/09/01/202509-技术日志/
作者
Noctis64
发布于
2025年9月1日
许可协议