202510|技术日志

1009

HashMap null key

HashMap 允许 null key 的存在

因为早期HashTable不支持,HashMap作为HashTable的后续替代方案,在设计结构上引入了对null key的支持

HashMap在计算null key的散列函数时默认会将null key永远设置在0号桶,也正因此HashMap只允许一个null key

虽然map.put(null, null)是一个合法操作,但是不推荐。因为会导致get(null)仍然返回null,而一般情况我们使用 map ,如果返回null就下意识的认为key不存在,在上面描述的这种情况下,key存在,只是key和value都为null;

因此判断key是否存在的最佳实践是:应当使用map.containsKey()

单核CPU支持多线程吗

单核CPU是支持的,在程序执行时底层通过操作系统时间片轮转的方式,把CPU的时间片分给不同的线程执行。

由于现代CPU的处理速度一般都比较快,虽然受限于单个核心,同一个时间只能执行一个线程,但是可以快速在多个线程之间进行切换,给我们一种多个任务在同时执行的”多线程”的感觉

1010

操作系统线程调度方案

线程调度是 操作系统 管理 程序线程执行顺序 以及 系统整体资源 的一个重要机制。

主要可以分为两种调度方案:

  • 非抢占式调度:任务必须主动让出CPU执行权,否则会一直占用CPU。也就是线程执行完毕后,主动通知系统切换到另一个线程
  • 抢占式调度:操作系统调度器来统一控制CPU的执行权,调度器可以强制中断CPU对于当前任务的处理,将CPU分配给更高优先级的任务

对比

抢占式 非抢占式(协同式调度)
切换机制 调度器强制中断 任务主动让出
资源开销 存在线程上下文切换开销 不存在线程上下文切换
死锁饥饿风险 低,调度器可介入干预 高,可能一直占用CPU资源
适用场景 对响应时间敏感的实时性场景,如现代操作系统;多线程编程场景 资源有限、协程或事件驱动框架

常见的抢占式调度机制

优先级调度:高优先级线程优先执行,Windows的线程调度使用的就是这个机制

时间片轮转:OS决定每个线程分配到固定时间片轮流执行

短任务优先:短任务可以抢占长任务的CPU执行权

单核多线程最佳实践场景

单核多线程适合IO密集型场景,而多核更适用于CPU密集型计算场景。

因为IO密集型的任务,CPU利用率低,更多时间是在等待IO设备,瓶颈在IO。采用单核多线程可以让单核的这个CPU在多个IO密集的任务之间轮转执行,最大化利用CPU;

而CPU密集型的任务,使用多核CPU运行能最大化利用CPU同时获得最大的执行效率。

1013

并发和并行的区别

并发Concurrency,对应单核CPU,多个任务同一时间只有一个任务在跑,源于操作系统的线程调度策略快速切换制造 “同时” 的错觉

并行Parallelism,对应多核CPU,多个任务实际真的在多个物理处理单元上执行

为什么ConcurrentHashMap不支持null key

关联[HashMap null key注意事项](#HashMap null key)

ConcurrentHashMap不仅不支持null key,甚至还不支持null value

二义性问题

当我们map.get(null)发现等于null的时候,就会出现一个问题:

  • 是Map中不包含这个null key
  • 还是Map中包含这个key,只是value是null

在HashMap中,设计之初就是为了单线程考虑的,我们可以进行这样的判断

1
2
3
4
5
6
if (map.get(null) == null) {
//调用 containsKey 判断 key 是否存在
if (!map.containsKey(null)) {
map.put(null, "error");
}
}

但是在ConcurrentHashMap中,由于是多线程环境,并且map.get()以及map.containsKey()这两个操作是两个独立的原子操作,因此中间可能存在 “线程切换导致的状态变化”,导致判断结果不一致。

1
2
3
4
5
6
7
8
9
10
11
12
//假设 ConcurrentHashMap 允许 null key
// 线程A执行
if (map.get(null) == null) { // 步骤1:线程A判断get(null)返回null(此时map中确实没有null key)
// 步骤2:线程A执行到此处时,CPU时间片耗尽,线程切换到B
if (!map.containsKey(null)) { // 步骤4:线程A恢复执行,判断containsKey(null)
// 步骤5:此时线程B已插入null key,因此containsKey(null)返回true,条件不成立
map.put(null, "error"); // 步骤6:因此这行代码不会执行,与程序预期出现偏差
}
}

// 线程B在步骤2和步骤4之间执行
map.put(null, "value"); // 步骤3:线程B插入null key,此时map中已有null key

1015

对象逃逸&标量替换

本内容属于Java内存模型优化、JVM内存管理相关知识点

对象逃逸

对象逃逸是指是对象的作用域超出了某个方法/线程

我们分三种情况进行讨论

  • 未逃逸
1
2
3
4
5
6
7
public void method() {
// 创建对象:仅在当前方法内使用
User user = new User();
user.setName("张三");
// 仅在当前方法内调用对象的方法,没有把对象传出
System.out.println(user.getName());
}
  • 方法逃逸

对象被作为方法返回值返回,或被传递给其他方法(但未被其他线程访问),导致其作用范围超出当前方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 场景1:对象被作为返回值返回
public User method1() {
User user = new User();
return user; // 对象逃出当前方法,被外部接收
}

// 场景2:对象被传递给其他方法
public void method2() {
User user = new User();
otherMethod(user); // 对象被传给其他方法,可能在其他方法中被进一步传递
}

public void otherMethod(User user) {
// 使用user...
}

上述两种方式本质上都是方法内创建的user对象作用域超出了本身的方法

  • 线程逃逸

在这种情况下,对象一般会被存储到类的静态变量、实例变量中,或被传递给其他线程(如作为线程的 Runnable 参数),导致其他线程可以访问该对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 场景1:对象被存储到静态变量(全局可见)
private static User staticUser;
public void method3() {
User user = new User();
staticUser = user; // 其他线程可以通过staticUser访问该对象
}

// 场景2:对象被传递给其他线程
public void method4() {
User user = new User();
new Thread(() -> {
// 其他线程访问该对象
System.out.println(user.getName());
}).start();
}

未逃逸对象栈上分配

在编译期间,JVM引入了一个机制:如果某个对象未发生逃逸,则会将这个对象分配到栈上

原因是因为栈相比于堆,更适合存储短期存在的对象,减轻GC压力

内存区域 特点 使用场景
线程共享,对象需要通过GC回收 长期存在的对象
线程私有,方法内产生创建,方法结束后自动销毁回收 短期存在的对象

未逃逸的对象生命周期和方法一致,方法调用时创建,方法执行结束后就应当被回收。这种特性刚好匹配栈的 “自动回收” 机制 —— 无需 GC 介入,方法执行完栈帧弹出,对象内存直接释放,效率远高于堆分配。

分配前提:未逃逸+标量替换

当然在栈上的对象并不是完整的对象实例,JVM会通过标量替换优化

1
2
3
4
5
6
7
8
9
10
11
public void method() {
// User对象未逃逸,可被标量替换
User user = new User();
user.name = "张三"; // 实际被替换为栈上的"张三"字符串引用
user.age = 20; // 实际被替换为栈上的int变量(值20)
}

class User {
String name;
int age;
}

由于对象未逃逸,只会在方法内部进行使用,因此在JVM的标量替换优化作用下,栈上并不会存储完整的User对象,而是直接在栈上分配 name(引用类型)和 age(int 类型)两个标量,方法结束后随栈帧销毁。

1016

包装类缓存

我们都知道包装类是在基础数据类型的基础上套了一层对象。为了减少对象的创建,JVM引入了对包装类的缓存机制:

在一定范围内,当我们通过自动装箱或使用**valueOf()方法创建包装类对象时,JVM 不会每次都创建新的对象,而是会从一个预先创建好的内部缓存数组(或称为对象池/常量池)**中直接返回已有对象的引用。

包装类 是否支持缓存 默认缓存范围 备注
Byte -128127 缓存所有可能的 Byte
Short -128127 范围较小,仅缓存常用范围
Integer -128127 最常用,可通过 JVM 参数 (-XX:AutoBoxCacheMax) 调整上限
Long -128127 范围较小,仅缓存常用范围
Character \u0000\u007F 对应十进制 0127,缓存ASCII字符集
Boolean truefalse 缓存所有可能的 Boolean 值(只有两个)
Float/Double 不缓存,因为浮点数的特性(数量过多、精度问题)不适合缓存

💡 重点关注:Integer 缓存:默认缓存上限 127 可以通过 JVM 启动参数 -XX:AutoBoxCacheMax=<size> 来调整(但下限 -128 是固定的,不能调整)

知道了这一个,下面我们看经典面试题:

1
2
3
4
5
6
7
Integer a = 100;
Integer b = 100;
Integer c = 200;
Integer d = 200;

System.out.println(a == b); // 1. true
System.out.println(c == d); // 2. false

100Integer 的缓存范围 [-128, 127] 内。ab 都是通过自动装箱(即调用 Integer.valueOf(100))创建。因此,ab 引用的是同一个缓存对象

而 200 超出了默认的缓存上限,因此 c 和 d 是两个不同的对象。

需要注意,并不是只要在缓存范围内命中就会返回缓存,只有自动装箱或是调用valueOf()的时候才会采用缓存。

如果直接new包装类对象,永远都不会命中缓存。

1
2
3
Integer i1 = 40;
Integer i2 = new Integer(40);
System.out.println(i1==i2); //false

最佳实践

在比较两个包装类对象的时候,始终选择用equals()来进行比较。避免使用 == 运算符,因为它比较的是对象的引用地址,容易因包装类的缓存机制的存在而产生意想不到的结果(如上面的 a == bc == d)。

但是如果比较的是一个基本数据类和包装类的时候,就要用 == 来比较了。因为此时会自动拆箱,通过 == 来进行基础数据类型的比较。

拼接字符串时,加号还是StringBuilder

场景 推荐方案 理由 核心面试考点
少量(2-3 次) 且在 同一行 拼接 使用 + 运算符 编译器会自动优化成 StringBuilder(语法糖),代码简洁。 编译器的自动优化
循环内大量(4 次及以上) 拼接 使用 StringBuilder 避免在循环中重复创建大量临时的 StringStringBuilder 对象,性能开销小。 String 的不可变性对象创建开销
多线程环境 下拼接 使用 StringBuffer StringBuffer线程安全 的,但性能比 StringBuilder 稍差。 线程安全性锁竞争

需要注意的是在循环中拼接,一定不能用加号,因为加号会触发编译器的语法糖,编译器会将其转为new StringBuilder().append().toString()假设循环 N 次,就会创建 N 个 StringBuilder 对象和 N 个新的 String 对象,导致大量的对象创建和内存开销,性能是 O(N2) 级别的。

如果有循环拼接的场景,且是单线程,采用 StringBuilder,可变字符串,直接修改 value数组

1021

整理Java内存模型笔记


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