在多线程编程中,延迟加载是一种常见的优化策略,它可以推迟对象的创建直到首次使用时。这种方式尤其适用于创建成本较高的对象或者可能在整个程序生命周期中并未使用的对象。本文将深入探讨两种在 Java 中实现线程安全延迟加载的方案:经典的双重检查锁定(Double-Checked Locking)和现代的原子化条件更新(Atomic Conditional Update)。
场景示例
假设我们需要实现一个线程安全的 Redis 参数管理类,该类在首次访问时初始化参数(redisParam),并且只初始化一次:
class RedisManagement {
private final AtomicReference<ImmutableMap<MQParam,String>> redisParam = new AtomicReference<>();
// 参数初始化相关方法
private String getRedisURI() { /* ... */ }
private String createCmdChannel() { /* ... */ }
private String createLogMonitorChannel() { /* ... */ }
private String createHeartbeatMonitorChannel() { /* ... */ }
}
方案一:双重检查锁定(Double-Checked Locking)
双重检查锁定是实现延迟初始化的经典模式,它通过两次检查来避免不必要的同步开销。
实现示例
protected Map<MQParam,String> getRedisParameters(){
// 第一次检查:避免不必要的同步
if (redisParam.get() == null) {
// 同步块确保线程安全
synchronized (redisParam) {
// 第二次检查:确保只初始化一次
if (redisParam.get() == null) {
Builder<MQParam, String> builder = ImmutableMap.<MQParam,String>builder()
.put(MQParam.REDIS_URI, getRedisURI())
.put(MQParam.CMD_CHANNEL, createCmdChannel())
.put(MQParam.LOG_MONITOR_CHANNEL, createLogMonitorChannel())
.put(MQParam.HB_MONITOR_CHANNEL, createHeartbeatMonitorChannel())
.put(MQParam.HB_INTERVAL, CONFIG.getInteger(HEARTBEAT_INTERVAL, DEFAULT_HEARTBEAT_PERIOD).toString())
.put(MQParam.HB_EXPIRE, CONFIG.getInteger(HEARTBEAT_EXPIRE, DEFAULT_HEARTBEAT_EXPIRE).toString());
if(!Strings.isNullOrEmpty(webredisURL)){
builder.put(MQParam.WEBREDIS_URL, webredisURL);
}
// 设置初始化后的值
redisParam.set(builder.build());
// 执行一次性副作用操作(如日志记录)
GlobalConfig.logRedisParameters(
JedisPoolLazys.NAMED_POOLS.getDefaultPool().getParameters());
}
}
}
return redisParam.get();
}
优点
- 性能优化:对象初始化后,后续访问不再需要同步开销
- 意图明确:这是一种广为人知的设计模式,大多数 Java 开发者都能理解
- 精细控制:可以精确控制何时执行副作用操作(如日志记录)
缺点
- 代码冗长:需要显式的 null 检查和同步块
- 容易出错:实现时容易遗漏 volatile 关键字或其他细节
- 样板代码:存在大量重复的检查和同步代码
方案二:原子化条件更新(Atomic Conditional Update)
这是一种更现代化的方法,利用 AtomicReference 的原子操作来实现线程安全的延迟初始化。
实现示例
protected Map<MQParam,String> getRedisParameters(){
return redisParam.updateAndGet(v -> {
// 只有在值为 null 时才执行初始化逻辑
if (v == null) {
Builder<MQParam, String> builder = ImmutableMap.<MQParam,String>builder()
.put(MQParam.REDIS_URI, getRedisURI())
.put(MQParam.CMD_CHANNEL, createCmdChannel())
.put(MQParam.LOG_MONITOR_CHANNEL, createLogMonitorChannel())
.put(MQParam.HB_MONITOR_CHANNEL, createHeartbeatMonitorChannel())
.put(MQParam.HB_INTERVAL, CONFIG.getInteger(HEARTBEAT_INTERVAL, DEFAULT_HEARTBEAT_PERIOD).toString())
.put(MQParam.HB_EXPIRE, CONFIG.getInteger(HEARTBEAT_EXPIRE, DEFAULT_HEARTBEAT_EXPIRE).toString());
if(!Strings.isNullOrEmpty(webredisURL)){
builder.put(MQParam.WEBREDIS_URL, webredisURL);
}
// 执行一次性副作用操作(如日志记录)
GlobalConfig.logRedisParameters(
JedisPoolLazys.NAMED_POOLS.getDefaultPool().getParameters());
// 返回新构建的对象
return builder.build();
}
// 否则返回原有值
return v;
});
}
工作原理分析
根据 OpenJDK 的实现,AtomicReference.updateAndGet方法的核心逻辑如下:
public final V updateAndGet(UnaryOperator<V> updateFunction) {
V prev, next;
do {
prev = get();
next = updateFunction.apply(prev);
} while (!compareAndSet(prev, next));
return next;
}
这个方法的工作原理是:
- 获取当前值(prev)
- 将当前值传递给 updateFunction 函数,获得新值(next)
- 使用 compare-and-swap(CAS)操作尝试用新值替换当前值
- 如果 CAS 操作失败(说明有其他线程同时修改了值),则重复以上步骤
- 最终返回更新后的值
在我们的延迟加载场景中,这意味着只有当当前值为 null 时,才会执行初始化逻辑并创建新对象。如果其他线程已经完成了初始化,那么 updateFunction 会直接返回已存在的值,而 compareAndSet 会发现值没有变化,从而不会进行任何更新。
优点
- 代码简洁:消除了显式的同步块和重复检查
- 原子性强:整个检查-初始化-设置过程在一个原子操作中完成
- 不易出错:不需要手动管理同步,降低了实现难度
- 现代化:符合函数式编程的思想
缺点
- 学习曲线:对于不熟悉函数式编程的开发者可能不够直观
- 细微差异:Lambda 表达式始终会被调用,尽管通常很快返回(现代 JVM 会优化这种情况)
性能对比
在大多数情况下,两种方案的性能差异可以忽略不计:
- 双重检查锁定在对象初始化后完全没有额外开销
- AtomicReference 方案每次调用都有极小的函数调用开销,但现代 JVM 能够很好地优化这种情况
如何选择?
选择双重检查锁定当你:
- 需要极致的性能优化
- 团队对经典设计模式更熟悉
- 需要精确控制副作用的执行时机
选择原子化条件更新当你:
- 希望代码更简洁、现代化
- 团队熟悉函数式编程概念
- 希望降低并发编程的复杂性
总结
两种方案都是实现线程安全延迟加载的有效方法。双重检查锁定是经典且经过验证的方式,而原子化条件更新则是更现代化的选择。选择哪种方案取决于团队的技术偏好、代码维护性要求以及具体的性能需求。
无论选择哪种方案,重要的是要理解其实现原理,并确保正确处理初始化过程中的副作用操作,比如日志记录或资源分配。
3103

被折叠的 条评论
为什么被折叠?



