0%

深入理解synchronized关键字

synchronized与原子性

先来看一个大家很熟悉的例子:

1
2
3
4
5
6
7
8
9
10
11
public class IntegerIncrementer {
private int count = 0;

public int getCount() {
return count;
}

public void increase() {
count++;
}
}

这段很简单的代码,在单线程中不会有任何问题。但是如果在多线程的环境当中,运行结果就很有可能并不是我们所想的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Demo implements Runnable {
private static IntegerIncrementer incrementer = new IntergerIncrementer();

public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
Thread thread = new Thread(new Demo());
thread.start();
}
System.out.println("result: " + incrementer.getCount());
}

@Override
public void run() {
incrementer.increase();
}
}

以上代码开了1000个线程,每个线程做的事情很简单,就是让incrementer自增,但是最后的结果却并不一定是1000。这是因为count++并不是一个原子性的操作,所谓原子性,就是指线程在执行这个指令的时候不会被打断,不会发生context switch。一个count++的操作,实际上包含了以下三步:

  1. 读取count的值。
  2. 对count加一。
  3. 将加一后的值赋值给count。

即使一般而言,1和3的操作在java中都被认为是具有原子性的(注意long和double在32位架构的CPU下不是原子操作,会分开成两次32位的读或者写),但是这三种操作组合起来并不具有原子性。比如可能发生以下情况:

  1. 线程A读取了count的值为9,对其加一。
  2. 在线程A赋值之前,线程B也读取了count的值为9,对其加一。
  3. 线程A对count赋值为10。
  4. 线程B对count赋值为10。

2的情况中,我们并不一定要求在线程A赋值之前B去读count这个情况才可能发生,因为即使线程A写入了,也不一定对B可见,这个我们之后在讨论。

在以上的情况中,count本应该最后变为11,但是最后的值却为10。很明显,在多线程环境中,我们无法确定程序最后输出的结果是什么,因为其完全取决于线程运行的情况。所以我们希望count++这个操作可以具有原子性,为了达到这个目的,我们可以用synchronized关键字。

synchronized可以用在以下场景:

  1. 静态方法: public static synchronized void method() { ... }
  2. 实例方法: public synchronized void method() { ... }
  3. 类对象: synchronized (Demo.class) { ... }
  4. 实例对象: synchronized (this) { ... }

每一个java的对象都有对应的monitor lock,这个monitor lock会在进入synchronized block的时候被线程自动获取,然后在离开的时候释放。这个锁一旦被某个线程获取,其他线程都无法获取,只能block,等占有的线程释放之后,再尝试获取。值得注意的是,java的monitor lock是可以重复获取的,如果一个线程已经获取了这个锁,那么当它再次请求的时候,这个锁对应的计数器会加一,每一次离开synchronized block都会减一,直到减为0,锁就会释放。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
public class BaseClass {
public synchronized void do() {
//Do something...
}
}

public class ChildClass extends BaseClass {
public synchronized void do() {
//Do something...
super.do();
}
}

如果我们直接调用ChildClassdo()方法,那么当前线程会获取ChildClassBaseClass的monitor lock,那么当其调用super.do()的时候,会尝试再次获取BaseClass的monitor lock,如果不允许重复获得锁的话,这个锁是被当前线程拥有,所以不可能再次获得,那么只会deadlock。

需要注意的是对象的monitor lock的和对选哪个本身的状态并没有太大的关系。获得了对象的monitor lock并不会防止其他线程访问对象,只会防止其他线程获得相同的monitor lock,其他线程仍然可以访问对象没有被synchronized guard的代码。

具体是使用synchronized method还是synchronized block,这取决与具体的情况,但是一般都需要注意避免在里面放入一些费时的操作,比如昂贵的计算、网络或者I/O请求。

synchronized与可见性

还是上面的例子,如果我们改成这样是不是就没问题了呢?

1
2
3
4
5
6
7
8
9
10
11
public class IntegerIncrementer {
private int counter = 0;

public int getCount() {
return counter;
}

public synchronized void increase() {
counter++;
}
}

很遗憾,答案是否定的。这里又会涉及到另一个问题,就是可见性的问题。简而言之,就是线程A的操作不一定马上对线程B可见。在JMM(Java Memory Model)的设计中,共享变量储存在主内存当中,每一个线程又会有自己的工作内存,这一块区域只对这个线程可见,线程对共享变量进行操作的时候会先读取到工作内存,修改然后在写回去。那么完全可能出现以下情况:

  1. 线程A从主内存读取counter,在工作内存中加一,写入工作内存的变量。
  2. 线程B从主内存读取counter。
  3. 线程A把counter写入主内存。

这样一来线程B最后读到的数据是stale data,再一次地,程序的运行结果取决于线程的运行的状况,输出无法预测。

同样我们可以利用synchrnized来解决这个问题,synchronized可以保证线程在释放锁的时候强制将值刷新到主内存,这样当释放锁的时候,新的值就即使对其他线程可见,保证了对共享变量更改的可见性。

所以当对共享变量进行操作的时候,如果有线程写入的话,读和写都需要进行加锁。

当然我们也可以使用volite关键字来达到可见性,我们会在另外的文章中进行讨论。

synchronized与有序性

在编程语言中,程序员所写的代码的顺序和最后机器执行的顺序并不一定是一样的,这是因为底层的编译器和处理器,包括JVM会对指令进行优化,从而达到更好的performance。对于一段代码只要在遵从as-if-serial语义的前提下——即无论如何重排序,单线程执行的结果不会改变——就可以对指令进行重排。在JMM中,因为多线程的情况,其会限制一些指令的重排,当然程序员也可以利用一些关键字来防止重排,这里我们不做深入讨论。

这里有序性指的是,程序是否按照代码的顺序执行,不会被底层的一些处理而重排。

synchronized并无法防止指令的重排,但其在一定程度上提供了有序性:

  1. 所有synchronized block同时只能有一个线程进入,这保证了block之间的有序性。
  2. 其会防止释放monitor lock和在block中写入变量的指令之间的重排,这保证了释放锁的时候,更新的值已经写入了主内存。

但是对于block中的指令,并没有办法防止重排。因为block中始终是单线程,只要遵从as-if-serial语义,即可重排。