【硬核总结】如何轻松实现只计算一次、惰性求值?良性竞争条件的广泛使用可能超过你的想象!String实际上是可变的?
惰性求值和单次计算是两种优化策略,常常结合使用以提高程序的效率。惰性求值推迟计算直到结果确实需要,避免不必要的资源消耗;单次计算则确保结果一旦计算后可重复使用,避免重复计算。两者结合能实现按需加载和高效复用,广泛应用于函数式编程、大数据处理等领域。
常见的应用场景包括配置加载、请求处理的幂等性、单例模式、缓存机制和性能优化等。
在内存优化方面,这些策略可以避免初始化那些在应用生命周期中可能从未被使用的对象或数据结构。
本文不讨论静态对象初始化的问题,其实现方式包括双重检查锁(DCL),Holder,枚举实现,final 实现,CAS实现等,已经被说烂了。虽然很多内容是互通的。
1. Map#computeIfAbsent
在多线程环境中,可以使用ConcurrentHashMap来确保原子操作;在单线程环境中,可以使用HashMap等实现。例如,Java 中 Multimap的手动实现:
arduino
map.computeIfAbsent(key, __ -> new ArrayList<>()).add(value);
这里computeIfAbsent的使用可以看成初始化内部集合(此处为List) 并返回这个集合,因此最佳实践是直接链式操作add即可。以下是一个不推荐的用法:
ini
map.computeIfAbsent(key, __ -> new ArrayList<>());
var list = map.get(key);
list.add(value);
2. 字段惰性计算
惰性计算可以有效推迟不必要的计算,直到确实需要时才进行,从而优化性能。当然注意不要滥用。
可以使用 Lombok @Getter(lazy = true) 注解轻松实现,其可以保证多线程下也只计算一次。
typescript
public class LazyExample {
@Getter(lazy = true)
private final String heavyComputation = performHeavyComputation();
private String performHeavyComputation() {
System.out.println("Performing heavy computation...");
// 模拟耗时操作
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "Result of heavy computation";
}
public static void main(String[] args) {
LazyExample example = new LazyExample();
System.out.println("LazyExample created.");
// 第一次访问heavyComputation时,才会执行performHeavyComputation
System.out.println("Heavy Computation: " + example.getHeavyComputation());
// 再次访问时,不会重新计算
System.out.println("Heavy Computation: " + example.getHeavyComputation());
}
}
我们来看下其底层实现,delombok结果为:
kotlin
public class LazyExample {
private final AtomicReference<Object> heavyComputation = new AtomicReference();
public LazyExample() {
}
public String getHeavyComputation() {
Object value = this.heavyComputation.get();
if (value == null) {
synchronized(this.heavyComputation) {
value = this.heavyComputation.get();
if (value == null) {
String actualValue = this.performHeavyComputation();
value = actualValue == null ? this.heavyComputation : actualValue;
this.heavyComputation.set(value);
}
}
}
return (String)(value == this.heavyComputation ? null : value);
}
}
// 略去其他方法
底层实现有以下特点:
- 使用
AtomicReference
和双重检查锁定(Double-Checked Locking)来确保线程安全 - 使用
AtomicReference
区分 未计算的空(null) 和计算后结果为空(null),属于编程"技巧"范畴。也可以使用标识字段实现。 - 需要注意value 是局部变量,value = xxx 是读操作,xxx = value 是写操作。额外提一句:我们需要使用 xxx = value 保证安全发布,这里使用了原子类保证了安全发布。
3. 纯函数 + 无锁 -> 并发高性能 + 可视为只计算一次
Race condition 应该翻译成竞争条件,因为 race 就是竞争的意思,而翻译成竞态条件属于纯纯的胡乱造词,矫揉造作,故作高深,自以为是。
Java String类实现了hashcode的惰性求值和缓存功能,避免了重复计算。
csharp
public int hashCode() {
// The hash or hashIsZero fields are subject to a benign data race,
// making it crucial to ensure that any observable result of the
// calculation in this method stays correct under any possible read of
// these fields. Necessary restrictions to allow this to be correct
// without explicit memory fences or similar concurrency primitives is
// that we can ever only write to one of these two fields for a given
// String instance, and that the computation is idempotent and derived
// from immutable state
int h = hash;
if (h == 0 && !hashIsZero) {
h = isLatin1() ? StringLatin1.hashCode(value)
: StringUTF16.hashCode(value);
if (h == 0) {
hashIsZero = true;
} else {
hash = h;
}
}
return h;
}
Java String 的实现中,对于 hashcode 并没有加锁,hashcode 在没有计算前为0,同时使用标志字段 hashIsZero 表示计算出的hash值为0的情况。这里使用了名为 benign race condition (良性竞争条件)的技巧,即在无竞争条件下,可以保证hashcode字段最终会被所有读取的线程读取到;在竞争条件或者使用本地缓存的情况下,有些线程会进行重复计算,但是由于
- 计算方法是没有副作用的纯函数
- 多次写入字段的值是相同的
- int 的写入是原子的
对用户来说是透明的:最终对于用户使用而言,其并不会感知到 String 内部实现实际上是可变的,如果把String看作黑盒的话,其就是不可变的。
使用良性竞争条件的优点是避免了额外的锁开销,当多线程调用方法时,依然保证数据正确性,同时性能损耗不大。这里需要注意的是,如果纯函数的计算消耗比较大时,使用良性竞争条件是不合适的。
还有很多场景使用到了这种技巧,这里仅列举一些常用的:
BigDecimal#precision
scala
public class BigDecimal extends Number implements Comparable<BigDecimal> {
private transient int precision;
/**
* 返回当前 BigDecimal 的精度(未缩放值的十进制位数)
*
* <p>零值的精度为 1
*/
public int precision() {
int result = precision;
if (result == 0) {
long s = intCompact;
// 根据当前值的存储形式选择计算方式
if (s != INFLATED)
result = longDigitLength(s); // 使用紧凑型 long 表示时的位数计算
else
result = bigDigitLength(intVal); // 使用 BigInteger 存储时的位数计算
precision = result; // 缓存计算结果避免重复计算
}
return result;
}
}
BigDecimal#stringCache
typescript
/**
* Used to store the canonical string representation, if computed.
*/
private transient String stringCache;
@Override
public String toString() {
String sc = stringCache;
if (sc == null) {
stringCache = sc = layoutChars(true);
}
return sc;
}
以下为ImmutableSet#asList 某个具体实现(因为不可变有多种实现,此处仅展示底层常用的)。实际上,不只是Guava,很多不可变实现的某些方法都使用了良性竞争条件的技巧,实现了惰性求值 + 结果缓存。
less
@GwtCompatible
abstract static class CachingAsList<E> extends ImmutableSet<E> {
@LazyInit @RetainedWith @CheckForNull private transient ImmutableList<E> asList;
@Override
public ImmutableList<E> asList() {
// 读操作
ImmutableList<E> result = asList;
if (result == null) {
// 写操作
return asList = createAsList();
} else {
// 考虑多线程访问,一定要返回局部变量,否则,如果改成两次读取,第一次读取结果可能为非空,第二次结果可能为空
return result;
}
}
ImmutableList<E> createAsList() {
return new RegularImmutableAsList<E>(this, toArray());
}
}
CFFU 实现中也使用到了这种技巧,由于涉及的具体逻辑与知识点有点复杂,感兴趣的读者可以自己研究。