Java 是如何解决原子性问题的?
一
原子性问题的源头是线程切换,如果能够禁用线程切换那不就能解决这个问题了吗?
而操作系统做线程切换是依赖 CPU 中断的,所以禁止 CPU 发生中断就能够禁止线程切换。
作者认为,在单核 CPU 时代,这个方案的确是可行的,但是并不适合多核场景。
二
如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,就都能保证原子性。 也即同一时刻只有一个线程执行,称之为互斥。 详见 简易锁模型和改进后的锁模型 两张示例图。
三
Java 语言通过 synchronized 关键字实现了锁 synchronized 既可以用来修饰方法,也可以用来修饰代码块。
Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock()
那 synchronized 里的加锁 lock() 和解锁 unlock() 锁定的对象在哪里呢?
当修饰静态方法的时候,锁定的是当前类的
Class对象,在上面的例子中就是Class X;
当修饰非静态方法的时候,锁定的是当前实例对象this
值得注意的是,修饰代码块的时候,需锁定一个 Object 对象。
四
来看一段代码
class SafeCalc {
long value = 0L;
long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}
被 synchronized 修饰后,无论是单核 CPU 还是多核 CPU,只有一个线程能够执行 addOne() 方法,所以一定能保证原子操作,那是否有可见性问题呢?
根据“对一个锁解锁 Happens-Before 后续对这个锁的加锁”这个规则,我们就能得出前一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的。
按照这个规则,如果多个线程同时执行 addOne() 方法,可见性是可以保证的,也就说如果有 1000 个线程执行 addOne() 方法,最终结果一定是 value 的值增加了 1000
最后,记得 get() 方法也要 synchronized 一下(详见保护临界区 get() 和 addOne() 的示意图)。
五
再看一段代码
class SafeCalc {
static long value = 0L;
synchronized long get() {
return value;
}
synchronized static void addOne() {
value += 1;
}
}
改动后的代码是用两个锁保护一个资源,由于临界区 get() 和 addOne() 是用两个锁保护的,因此这两个临界区没有互斥关系,临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证,这就导致并发问题了(详见两把锁保护一个资源的示意图)。
不过,作者认为倒是可以用同一把锁来保护多个资源。
六
只要有了并发问题,大家首先容易想到的就是加锁,因为大家都知道,加锁能够保证执行临界区代码的互斥性。
但作者认为,还得深入分析锁定的对象和受保护资源的关系,综合考虑受保护资源的访问路径,多方面考量才能用好互斥锁。