Synchronized

需求

多线程共享内存的两个问题,一个是竞态条件,另一个是内存可见性

竞态条件

所谓竞态条件(race condition)是指,当多个线程访问和操作同一个对象时,最终执行结果与执行时序有关,可能正确也可能不正确,我们看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class CounterThread extends Thread {
private static int counter = 0;

@Override
public void run() {
for (int i = 0; i < 1000; i++) {
counter++;
}
}

public static void main(String[] args) throws InterruptedException {
int num = 1000;
Thread[] threads = new Thread[num];
for (int i = 0; i < num; i++) {
threads[i] = new CounterThread();
threads[i].start();
}

for (int i = 0; i < num; i++) {
threads[i].join();
}

System.out.println(counter);
}
}

这段代码容易理解,有一个共享静态变量counter,初始值为0,在main方法中创建了1000个线程,每个线程对counter循环加1000次,main线程等待所有线程结束后输出counter的值。

期望的结果是100万,但实际执行,发现每次输出的结果都不一样,一般都不是100万,经常是99万多。为什么会这样呢?因为counter++这个操作不是原子操作,它分为三个步骤:

  1. 取counter的当前值
  2. 在当前值基础上加1
  3. 将新值重新赋值给counter

两个线程可能同时执行第一步,取到了相同的counter值,比如都取到了100,第一个线程执行完后counter变为101,而第二个线程执行完后还是101,最终的结果就与期望不符。

怎么解决这个问题呢?有多种方法:

  • 使用synchronized关键字
  • 使用显式锁
  • 使用原子变量

关于这些方法,我们在后续章节再介绍。

内存可见性

多个线程可以共享访问和操作相同的变量,但一个线程对一个共享变量的修改,另一个线程不一定马上就能看到,甚至永远也看不到,这可能有悖直觉,我们来看一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class VisibilityDemo {
private static boolean shutdown = false;

static class HelloThread extends Thread {
@Override
public void run() {
while(!shutdown){
// do nothing
}
System.out.println("exit hello");
}
}

public static void main(String[] args) throws InterruptedException {
new HelloThread().start();
Thread.sleep(1000);
shutdown = true;
System.out.println("exit main");
}
}

在这个程序中,有一个共享的boolean变量shutdown,初始为false,HelloThread在shutdown不为true的情况下一直死循环,当shutdown为true时退出并输出”exit hello”,main线程启动HelloThread后睡了一会,然后设置shutdown为true,最后输出”exit main”。

期望的结果是两个线程都退出,但实际执行,很可能会发现HelloThread永远都不会退出,也就是说,在HelloThread执行流看来,shutdown永远为false,即使main线程已经更改为了true。

这是怎么回事呢?这就是内存可见性问题。在计算机系统中,除了内存,数据还会被缓存在CPU的寄存器以及各级缓存中,当访问一个变量时,可能直接从寄存器或CPU缓存中获取,而不一定到内存中去取,当修改一个变量时,也可能是先写到缓存中,而稍后才会同步更新到内存中。在单线程的程序中,这一般不是个问题,但在多线程的程序中,尤其是在有多CPU的情况下,这就是个严重的问题。一个线程对内存的修改,另一个线程看不到,一是修改没有及时同步到内存,二是另一个线程根本就没从内存读。

用法

synchronized 可以用于修饰类的方法 静态方法 和 代码块

实例方法

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

public synchronized void incr(){
count ++;
}

public synchronized int getCount() {
return count;
}
}

注意:

  • 多个线程是可以同时执行同一个synchronized实例方法的,只要它们访问的对象是不同的
  • synchronized方法不能防止非synchronized方法被同时执行 (一般在保护变量时,需要在所有访问该变量的方法上加上synchronized)

synchronized实例方法保护的是当前实例对象,即this,this对象有一个锁和一个等待队列,锁只能被一个线程持有,其他试图获得同样锁的线程需要等待,执行synchronized实例方法的过程大概如下:

  1. 尝试获得锁,如果能够获得锁,继续下一步,否则加入等待队列,阻塞并等待唤醒
  2. 执行实例方法体代码
  3. 释放锁,如果等待队列上有等待的线程,从中取一个并唤醒,如果有多个等待的线程,唤醒哪一个是不一定的,不保证公平性

synchronized的实际执行过程比这要复杂的多,而且Java虚拟机采用了多种优化方式以提高性能,但从概念上,我们可以这么简单理解。

当前线程不能获得锁的时候,它会加入等待队列等待,线程的状态会变为BLOCKED。

静态方法

synchronized同样可以用于静态方法,比如:

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

public static synchronized void incr() {
count++;
}

public static synchronized int getCount() {
return count;
}
}

前面我们说,synchronized保护的是对象,对实例方法,保护的是当前实例对象this,对静态方法,保护的是哪个对象呢?是类对象,这里是StaticCounter.class,实际上,每个对象都有一个锁和一个等待队列,类对象也不例外。

synchronized静态方法和synchronized实例方法保护的是不同的对象,不同的两个线程,可以同时,一个执行synchronized静态方法,另一个执行synchronized实例方法。

代码块

除了用于修饰方法外,synchronized还可以用于包装代码块,比如对于前面的Counter类,等价的代码可以为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Counter {
private int count;

public void incr(){
synchronized(this){
count ++;
}
}

public int getCount() {
synchronized(this){
return count;
}
}
}

synchronized括号里面的就是保护的对象,对于实例方法,就是this,{}里面是同步执行的代码。

对于前面的StaticCounter类,等价的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class StaticCounter {
private static int count = 0;

public static void incr() {
synchronized(StaticCounter.class){
count++;
}
}

public static int getCount() {
synchronized(StaticCounter.class){
return count;
}
}
}

synchronized同步的对象可以是任意对象,任意对象都有一个锁和等待队列,或者说,任何对象都可以作为锁对象。比如说,Counter的等价代码还可以为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Counter {
private int count;
private Object lock = new Object();

public void incr(){
synchronized(lock){
count ++;
}
}

public int getCount() {
synchronized(lock){
return count;
}
}
}

特性

可重入性

synchronized有一个重要的特征,它是可重入的,也就是说,对同一个执行线程,它在获得了锁之后,在调用其他需要同样锁的代码时,可以直接调用,比如说,在一个synchronized实例方法内,可以直接调用其他synchronized实例方法。可重入是一个非常自然的属性,应该是很容易理解的,之所以强调,是因为并不是所有锁都是可重入的(后续章节介绍)。

内存可见性

同步容器和并发容器

同步容器:

它们可以返回线程安全的同步容器,比如:

1
2
3
public static <T> Collection<T> synchronizedCollection(Collection<T> c)
public static <T> List<T> synchronizedList(List<T> list)
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m)

加了synchronized,所有方法调用变成了原子操作,客户端在调用时,是不是就绝对安全了呢?不是的,至少有以下情况需要注意:

  • 复合操作,比如先检查再更新
  • 伪同步
  • 迭代

并发容器:

除了以上这些注意事项,同步容器的性能也是比较低的,当并发访问量比较大的时候性能很差。所幸的是,Java中还有很多专为并发设计的容器类,比如:

  • CopyOnWriteArrayList
  • ConcurrentHashMap
  • ConcurrentLinkedQueue
  • ConcurrentSkipListSet

这些容器类都是线程安全的,但都没有使用synchronized、没有迭代问题、直接支持一些复合操作、性能也高得多

作者:swiftma
链接:https://juejin.im/post/58a31c672f301e00690e08a7
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

lemon wechat
欢迎大家关注我的订阅号 SeeMoonUp
写的不错?鼓励一下?不差钱?