一个或者多个操作在CPU执行的过程中不被中断的特性,称为原子性。
long型变量(8字节64位)在32位机器上被拆分为高32位&低32位,读写可能出现诡异Bug。
解决原子性问题
原子性问题的源头是线程切换。禁用线程切换就能解决这个问题。操作系统做线程切换是依赖CPU中断的,禁止CPU发生中断就能够禁止线程切换。在早期单核CPU时代,上述方案可行,但并不适合多核场景。
32位CPU上执行long型变量的写操作:long型变量是64位,在32位CPU上执行写操作会被拆分成两次写操作(写高32位和写低32位)。
在单核CPU场景下,同一时刻只有一个线程执行,禁止CPU中断,意味着操作系统不会重新调度线程,也就是禁止了线程切换,获得CPU使用权的线程就可以不间断的执行,所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性。
在多核场景下,同一时刻,有可能有两个线程同时在执行,一个线程执行在CPU-1上,一个线程执行在CPU-2上,此时禁止CPU中断,只能保证CPU上的线程连续执行,并不能保证同一时刻只有一个线程执行,如果这两个线程同时写long型变量高32位的话,就有可能出现诡异的Bug。
“同一时刻只有一个线程执行”这个条件非常重要,称之为互斥。如果能够保证对共享变量的修改是互斥的,那么,无论是单核CPU还是多核CPU,就都能保证原子性了。
简易锁模型
互斥的杀手级解决方案:锁。
把一段需要互斥执行的代码称为临界区。线程在进入临界区之前,首先尝试加锁lock(),如果成功,则进入临界区,此时称这个线程持有锁;否则就等待,直到持有锁的线程解锁;持有锁的线程执行完临界区的代码后,执行解锁unlock()。(锁的是什么,保护的是什么)
改进后的锁模型
现实世界中,锁和锁要保护的资源时有对应关系的。在并发编程世界里,锁和资源也应该有这个关系。
首先,要把临界区要保护的资源标注出来,临界区里增加一个元素:受保护的资源R;其次,要保护资源R就得为它创建一把锁LR;最后,针对这把锁LR,需要在进出临界区时添上加锁操作和解锁操作。

另外,在锁LR和受保护资源之间的关联关系非常重要(黑色箭头线)。很多并发Bug的出现都是因为把它忽略了,然后就出现了类似锁自家门来保护他家资产的事情,这样的Bug非常不好诊断,因为潜意识里认为已经正确加锁了。
Java语言提供的锁技术:synchronized
锁是一种通用的技术方案,Java语言提供的synchronized关键字,就是锁的一种实现。
synchronized关键字可以用来修饰方法,也可以用来修饰代码块。
1 | class X { |
Java编译器会在synchronized修饰的方法或代码块前后自动加上加锁lock()和解锁unlock(),这样做的好处是加锁lock()和解锁unlock()一定是成对出现的,忘记解锁unlock()是个致命的Bug(其他线程只能死等下去)。
修饰代码块的时候,锁定了一个obj对象。修饰方法的时候:当修饰静态方法的时候,锁定的是当前类的Class对象(Class X);当修饰非静态方法的时候,锁定的是当前实例对象this。
1 | class X { |
用synchronized解决count+=1问题
1 | class SafeCalc { |
addOne()方法,被synchronized修饰后,无论是单核CPU还是多核CPU,只有一个线程能够执行addOnew()方法,一定能够保证原子操作。
synchronized修饰的临界区时互斥的,也就是同一时刻只有一个线程执行临界区的代码;根据管程中锁的规则“对一个锁的解锁Happens-Before于后续对这个锁的加锁”,指的是前一个线程的解锁操作对后一个线程的加锁操作可见,综合Happens-Before的传递性原则,得出前一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的。
按照上述规则,如果多个线程同时执行addOne()方法,可见性是可以保证的。
执行addOne()方法后,value的值对get()方法的可见性是无法保证的。管程中锁的规则,是只保证后续对这个锁的加锁的可见性,而get()方法并没有加锁操作,所以可见性没法保证。
只是不允许两个线程同时进入临界区,没有其他约束。(get方法没有加锁,不会被锁住可以正常访问value)
get()方法也需要synchronized修饰。(value变量用volatile修饰可保证可见性,但无法保证读long类型的原子性)
1 | class SafeCalc { |
get()方法和addOne()方法都需要访问value这个受保护的资源,这个资源用this这把锁来保护。线程要进入临界区get()和addOne(),必须先获得this这把锁,这样get()和addOne()也是互斥的。
不允许两个线程同时进入临界区。

类似现实世界中门票管理,一个座位只允许一个人使用,这个座位就是”受保护资源”,场地入口就是Java类里的方法,而门票就是用来保护资源的”锁”,Java里的检票工作是由synchronized解决的。
锁和受保护资源的关系
受保护资源和锁之间的关联关系非常重要。一个合理的关系是:受保护资源和锁之间的关联关系是N:1的关系。(一个座位,只能用一张票来保护,如果多发了重复的票,那就要打架了。现实世界中,可以用多把锁来保护同一个资源,但在并发领域不行,并发领域的锁和现实世界的锁不是完全匹配的。但是可以用同一把锁来保护多个资源,这个对应到现实世界中就是所谓的“包场”。)
1 | class SafeCalc { |
改动后的代码是用两个锁保护一个资源。这个受保护的资源就是静态变量value,两个锁分别是this和SafeCalc.class。
类锁和实例锁之间是平行的,平级的,互不影响。
由于临界区get()和addOne()是用两个锁保护的,因此这两个临界区没有互斥关系,临界区addOne()对value的修改对临界区get()也没有可见性保证,这就导致并发问题了。(一个锁能够保护多个共享资源解决并发问题;多个锁保护一个共享资源依旧会有并发问题)

总结
互斥锁,在并发领域的知名度极高,只要有了并发问题,首先容易想到的就是加锁,加锁能够保证执行临界区代码的互斥性。但是这种理解不能够真正用好锁。
临界区的代码时操作受保护资源的路径,类似于场地的入口,入口一定要检票,也就是要加锁,但是不是随便一把锁都能有效。必须深入分析锁定的对象和受保护资源的关系,综合考虑受保护资源的访问路径,多方面考量才能用好互斥锁。
synchronized是Java在语言层面提供的互斥原语,Java里面还有很多其他类型的锁,作为互斥锁,原理都是想通的:锁,一定有一个要锁定的对象,至于这个锁定的对象要保护的资源以及在哪里加锁/解锁,属于设计层面的事情。
加锁本质是在锁对象的对象头中写入当前线程id。