synchronized与原子性
1 | public class IntegerIncrementer { |
这段很简单的代码,在单线程中不会有任何问题。但是如果在多线程的环境当中,运行结果就很有可能并不是我们所想的情况:
1 | public class Demo implements Runnable { |
以上代码开了1000个线程,每个线程做的事情很简单,就是让incrementer自增,但是最后的结果却并不一定是1000。这是因为count++
并不是一个原子性的操作,所谓原子性,就是指线程在执行这个指令的时候不会被打断,不会发生context switch。一个count++
的操作,实际上包含了以下三步:
- 读取count的值。
- 对count加一。
- 将加一后的值赋值给count。
即使一般而言,1和3的操作在java中都被认为是具有原子性的(注意long和double在32位架构的CPU下不是原子操作,会分开成两次32位的读或者写),但是这三种操作组合起来并不具有原子性。比如可能发生以下情况:
- 线程A读取了count的值为9,对其加一。
- 在线程A赋值之前,线程B也读取了count的值为9,对其加一。
- 线程A对count赋值为10。
- 线程B对count赋值为10。
2的情况中,我们并不一定要求在线程A赋值之前B去读count这个情况才可能发生,因为即使线程A写入了,也不一定对B可见,这个我们之后在讨论。
在以上的情况中,count本应该最后变为11,但是最后的值却为10。很明显,在多线程环境中,我们无法确定程序最后输出的结果是什么,因为其完全取决于线程运行的情况。所以我们希望count++
这个操作可以具有原子性,为了达到这个目的,我们可以用synchronized
关键字。
synchronized
可以用在以下场景:
- 静态方法:
public static synchronized void method() { ... }
- 实例方法:
public synchronized void method() { ... }
- 类对象:
synchronized (Demo.class) { ... }
- 实例对象:
synchronized (this) { ... }
每一个java的对象都有对应的monitor lock,这个monitor lock会在进入synchronized block的时候被线程自动获取,然后在离开的时候释放。这个锁一旦被某个线程获取,其他线程都无法获取,只能block,等占有的线程释放之后,再尝试获取。值得注意的是,java的monitor lock是可以重复获取的,如果一个线程已经获取了这个锁,那么当它再次请求的时候,这个锁对应的计数器会加一,每一次离开synchronized block都会减一,直到减为0,锁就会释放。举个例子:
1 | public class BaseClass { |
如果我们直接调用ChildClass
的do()
方法,那么当前线程会获取ChildClass
和BaseClass
的monitor lock,那么当其调用super.do()
的时候,会尝试再次获取BaseClass
的monitor lock,如果不允许重复获得锁的话,这个锁是被当前线程拥有,所以不可能再次获得,那么只会deadlock。
需要注意的是对象的monitor lock的和对选哪个本身的状态并没有太大的关系。获得了对象的monitor lock并不会防止其他线程访问对象,只会防止其他线程获得相同的monitor lock,其他线程仍然可以访问对象没有被synchronized
guard的代码。
具体是使用synchronized method还是synchronized block,这取决与具体的情况,但是一般都需要注意避免在里面放入一些费时的操作,比如昂贵的计算、网络或者I/O请求。
synchronized与可见性
还是上面的例子,如果我们改成这样是不是就没问题了呢?
1 | public class IntegerIncrementer { |
很遗憾,答案是否定的。这里又会涉及到另一个问题,就是可见性的问题。简而言之,就是线程A的操作不一定马上对线程B可见。在JMM(Java Memory Model)的设计中,共享变量储存在主内存当中,每一个线程又会有自己的工作内存,这一块区域只对这个线程可见,线程对共享变量进行操作的时候会先读取到工作内存,修改然后在写回去。那么完全可能出现以下情况:
- 线程A从主内存读取counter,在工作内存中加一,写入工作内存的变量。
- 线程B从主内存读取counter。
- 线程A把counter写入主内存。
这样一来线程B最后读到的数据是stale data,再一次地,程序的运行结果取决于线程的运行的状况,输出无法预测。
同样我们可以利用synchrnized
来解决这个问题,synchronized
可以保证线程在释放锁的时候强制将值刷新到主内存,这样当释放锁的时候,新的值就即使对其他线程可见,保证了对共享变量更改的可见性。
所以当对共享变量进行操作的时候,如果有线程写入的话,读和写都需要进行加锁。
当然我们也可以使用volite
关键字来达到可见性,我们会在另外的文章中进行讨论。
synchronized与有序性
在编程语言中,程序员所写的代码的顺序和最后机器执行的顺序并不一定是一样的,这是因为底层的编译器和处理器,包括JVM会对指令进行优化,从而达到更好的performance。对于一段代码只要在遵从as-if-serial
语义的前提下——即无论如何重排序,单线程执行的结果不会改变——就可以对指令进行重排。在JMM中,因为多线程的情况,其会限制一些指令的重排,当然程序员也可以利用一些关键字来防止重排,这里我们不做深入讨论。
这里有序性指的是,程序是否按照代码的顺序执行,不会被底层的一些处理而重排。
synchronized
并无法防止指令的重排,但其在一定程度上提供了有序性:
- 所有synchronized block同时只能有一个线程进入,这保证了block之间的有序性。
- 其会防止释放monitor lock和在block中写入变量的指令之间的重排,这保证了释放锁的时候,更新的值已经写入了主内存。
但是对于block中的指令,并没有办法防止重排。因为block中始终是单线程,只要遵从as-if-serial
语义,即可重排。