一位同学在群里说碰到 奇怪bug,示例代码如下
@Slf4j
public class Interesting {
volatile int a = 1;
volatile int b = 1;
public void add() {
log.info("add start");
for (int i = 0; i < 10000; i++) {
a++;
b++;
}
log.info("add done");
}
public void compare() {
log.info("compare start");
for (int i = 0; i < 10000; i++) {
//a始终等于b吗?
if (a < b) {
log.info("a:{},b:{},{}", a, b, a > b);
//最后的a>b应该始终是false吗?
}
}
log.info("compare done");
}
}
他起了两个线程来分别执行 add 和 compare 方法:
Interesting interesting = new Interesting();
new Thread(() -> interesting.add()).start();
new Thread(() -> interesting.compare()).start();
按道理,a 和 b 同样进行累加操作,应该始终相等,compare 中的第一次判断应该始终不 会成立,不会输出任何日志。但,执行代码后发现不但输出了日志,而且更诡异的是, compare 方法在判断 ab 也成立:
群里一位同学看到这个问题笑了,说:“是线程安全问题嘛。很 明显,你这是在操作两个字段 a 和 b,有线程安全问题,应该为 add 方法加上锁,确保 a 和 b 的 ++ 是原子性的,就不会错乱了。”随后,他为 add 方法加上了锁:
public synchronized void add()
为什么锁可以解决线程安全问题呢。因为只有一个线程可以拿到锁,所 以加锁后的代码中的资源操作是线程安全的。但是,这个案例中的 add 方法始终只有一个线程在操作,显然只为 add 方法加锁是没用的。
之所以出现这种错乱,是因为两个线程是交错执行 add 和 compare 方法中的业务逻辑, 而且这些业务逻辑不是原子性的:a++ 和 b++ 操作中可以穿插在 compare 方法的比较代 码中;更需要注意的是,a<b 这种操作在字节码层面是加载 a、加载 b 和比较三步, 代码虽然是一行但也不是原子性的(volatile 只能保证有序性和可见性,不能保证原子性)。
所以,正确的做法应该是,为 add 和 compare 都加上方法锁,确保 add 方法执行时, compare 无法读取 a 和 b
总结:加锁前要清楚锁和被保护的对象是不是一个层面的
小插曲:
变量 a、b 都使用了 volatile 关键字,能不能不使用?
答案:
必须加volatile,因为volatile保证了可见性。改完后会强制让工作内存失效。去主存拿。如果不加volatile的话那么在 for 语句里面添加输出语句也是OK的。因为println源码加锁了,sync会让当前线程的工作内存失效。必须加volatile或者使用AtomicBoolean/AtomicReference等也行,后者相比volatile除了确保可见性还提供了CAS方法保证原子性。