synchronized关键字

synchronized关键字

自Java 1.0开始,每一个对象都有一个隐式内部锁intrinsic lock ),在Java API Specification中通常被称为监视器monitor )。这个内部锁由synchronized关键字提供支持。synchronized关键字的语义就是“同步的”,这意味着使用这个关键字可以处理共享资源的冲突。

当访问被synchronized关键字保护的方法或代码块时,它将检查锁能否获得——这个锁可以是当前类对象的锁,也可以是一个临时锁( ad-hoc lock ),取决你如何使用,任务执行完成之后会释放锁。

ReentrantLock一样,synchronized关键字获取的锁也是独占锁,并且也是“可重入”的,某个任务可以多次获得对象的锁,并由计数器维护获得锁的次数,当退出一个方法时,计数器-1,完全退出时,才释放锁,这和可重入锁的机制是一样的。

类对象也持有一个锁,也就是说synchronized关键字可作用于静态方法

关于什么时候该使用同步, Brian Goetz 提出过同步规则

若向一个变量写入值,它可能接下来被另一个线程读取,或者正在读取一个上一次由另一个线程写过的值,那么必须使用同步,并且读写线程都必须使用相同的监视器同步

监视器是由 Per Brinch HansenTony Hoare 提出的一种无锁机制,最初的监视器具有如下特性:

  1. 监视器是只包含私有域的类
  2. 每个监视器的类对象有一个相关的锁
  3. 使用该锁对所有相关的方法加锁
  4. 该锁可以有任意多个相关条件

Java不完全地采用了监视器的设计概念,这就是synchronized关键字。

使用synchronized关键字时,将共享域设为私有是非常重要的。由于域只能通过方法访问,而synchronized保证方法执行的有序性;

若域不是私有的,其他任务可以直接操作域,这就可能产生冲突。

同步方法 #

synchronized关键字作用于方法时,表示这个方法是同步的,执行方法时,首先会尝试获取当前对象的锁——这个对象一般是类的实例对象( this ),若是静态方法,便是类对象。

 1public synchronized void transfer(int from, int to, double amount) 
 2throws InterruptedException {
 3  if (accounts[from] < amount) wait();  // can be interrupted
 4  if (from == to) return;
 5  // transfer
 6  accounts[from] -= amount;
 7  System.out.println(Thread.currentThread() + " move away");
 8  accounts[to] += amount;
 9  System.out.printf("%s: %10.2f from %d to %d, Total Balance: %10.2f%n",
10                    Thread.currentThread(),
11                    amount,
12                    from,
13                    to,
14                    totalBalance());
15  notifyAll();  // wake up all threads waiting on this monitor
16}

考虑转账的任务,只需要将transfer()方法加上synchronized关键字即可保证安全,运行此方法时,线程会先去获取Bank实例的内部锁,并将其他线程阻塞,此线程完成之后会释放这个对象锁,其他线程方可继续运行。

继续思考之前的问题,对于使用synchronized关键字的transfer()方法,里面调用了totalBalance()方法,那totalBalance()方法是否需要同步呢?前面说过「是否加锁应该以 资源是否共享为参照」,这其实和“同步法则“是的表述是一致的。如果有多个线程访问transfer()方法,正好此方法是串行访问(有序访问)的,那么totalBalance()方法无需同步;若还有其他线程对访问totalBalance()方法的资源,那么必须使用同步。

同步代码块 #

synchronized关键字也可以用于同步代码块(同步阻塞)。

在用于同步方法时,相当于synchronized(this),而同步代码块则多了一点灵活性。

1synchronized (obj){ // synchronized block
2  // critical section
3}

示例中的obj可以是 this ,也可以是其他对象。

考虑 资源访问受限引论中的EvenGenerator类,在next()方法中可以使用同步代码块加锁可保证安全性:

 1static class EvenGenerator extends AbstractIntGenerator {
 2  private Integer even = 0;
 3  private Object lock = new Object();
 4
 5  @Override
 6  public int next() {
 7    // equals to using
 8    // synchronized (this){
 9    synchronized (lock) {
10      ++even;
11      Thread.yield();
12      ++even;
13      // return语句必须包含在同步代码块里
14      return even;
15    }
16  }
17}

上例中,synchronized关键字使用了“其他对象”作为“监视器”,注意,synchronized代码块必须包括所有读写域的代码,包括return语句

字节码来看,return语句也不是原子性的——它要先加载并获取变量域even的值,然后再返回

Java语言规范规定对变量的读写都是原子的(long和double)除外,因此return语句是原子的。但是单一语句的原子性并不能保证多线程的安全性,如果锁在return之前被释放,那么return可能获取到其他线程修改后的值。

可以看到,使用synchronized关键字比使用显式锁代码更加简洁。

需要注意的是,尽管synchronized代码块中的锁可以是任意对象的,但是尽量不要把这种任意性视为绝对安全的。一般在同步代码块中使用this或某“不可变”域(上例中)的锁

考虑如下示例:

 1static class Bank {
 2  private final Vector<Double> accounts;
 3
 4  public Bank(int accountCount, double money) {
 5    // initialize bank account
 6    accounts = new Vector<>(accountCount);
 7    List<Double> doubles = Collections.nCopies(accountCount, money);
 8    accounts.addAll(doubles);
 9  }
10
11  public void transfer(int from, int to, double amount) {
12    synchronized (accounts) {
13      if (accounts.get(from) < amount) return;
14      if (from == to) return;
15      // transfer
16      accounts.set(from, accounts.get(from) - amount);
17      System.out.println(Thread.currentThread() + " move away");
18      accounts.set(to, accounts.get(to) + amount);
19      System.out.printf("%s: %10.2f from %d to %d, Total Balance: %10.2f%n",
20                        Thread.currentThread(),
21                        amount,
22                        from,
23                        to,
24                        totalBalance());
25    }
26  }
27}

上例中使用Vector作为账户的容器,Vector是线程安全的实现,是否可以不加锁呢?

不是的,Vector只能保证其实现方法是线程安全的,并不能保证transfer方法是同步的。换言之,accounts.set()方法是同步的,其完成之后该线程可能被剥夺运行权。

作为改进,在transfer()方法中截获了accounts的锁,尝试使其同步,它是可行的。但是这是否意味着可以任意使用其他对象的锁呢?Java核心卷I给出一段晦涩的评论1

如果冒昧地使用某个其他域(客户端锁定)的锁,可能不能保证安全性

This approach works, but it is entirely dependent on the fact that the Vector class uses the intrinsic lock for all of its mutator methods. However, is this really a fact? The documentation of the Vector class makes no such promise. You have to carefully study the source code and hope that future versions do not introduce unsynchronized mutators. As you can see, client-side locking is very fragile and not generally recommended.

其晦涩之处在于,synchronized使用accounts的内部锁保证同步,和Vector方法使用的锁是不是accounts的内部锁有什么联系?

如何使用同步 #

从之前的阐述我们知道,如果多个线程同时对共享资源进行访问,并且至少有一个线程对资源进行了写操作,那就需要同步。

在编写同步代码的时候,我常常困惑,应该在哪里使用同步呢?究竟是在线程上同步还是应该在资源方法上同步,还是所有位置都需要同步?

接下来我们从两个维度去剖析“在哪里同步”这个问题。

在资源上同步 #

 1// 资源
 2synchronized void next(){
 3    x++;
 4}
 5
 6// 任务1
 7run(){
 8    next();
 9}
10
11// 任务2
12run(){
13    next();
14}

这是常见的模式。当在资源上同步时,使用多线程执行任务1和任务2,都不会出现线程安全的问题。因为每一个对x进行操作的线程都会被同步阻塞。这就是资源的序列化访问。

在任务上同步 #

 1
 2final Lock lock ;
 3// 资源
 4void next(){
 5    x++;
 6}
 7
 8// 任务1
 9run(){
10    synchronized(lock){
11        next();
12    }
13}
14
15// 任务2
16run(){
17    next();
18}

如上代码示例所示,我们在任务1的run()方法上使用同步,当多个线程实例执行任务1时,x是线程安全的。

需要提出的是,run()方法中的synchronized使用的锁不能是this,如果是this,那么同步块将毫无作用。

因为synchronized是对持有对象的可重入锁,而this并不是指代的某个实例,而是所有构造的实例。

可以使用ClassName.class来持有类对象的锁来代替。

但是若此时有线程执行任务2,那么此代码的安全隐患就出现了:任务2的操作和任务1的操作就会互相干扰!

若想保证线程安全,那么任务2的next方法也要和任务1一样使用同步,并且使用相同的对象锁

这样的条件下,同时运行任务1和任务2,那么线程会在lock对象上获取锁而进入同步阻塞,从而保证安全性,和在资源上同步的效果是等同的。

建议 #

从代码的简洁性,可读性与可复用性上来讲,在资源上使用同步显得更加优雅,两种实现方式的代码可以进行比较直观的对比:

 1// 在任务上同步
 2public TV call() {
 3    while (true) {
 4        synchronized (tick) {
 5            TV tv = tl.get();
 6            tv.setT(Thread.currentThread());
 7            if (tick.getTick()) {
 8                tv.setV((tv.getV() == null ? 0 : tv.getV()) + 1);
 9                tl.set(tv);
10                try {
11                    // 给其他线程机会
12                    tick.wait(10);
13                } catch (InterruptedException e) {
14                    e.printStackTrace();
15                }
16            } else {
17                if (!tick.isTickSupply) break;
18            }
19        }
20    }
21    return tl.get();
22}
23
24// 在资源上使用同步
25public TV call() {
26    while (true) {
27        TV tv = tl.get();
28        tv.setT(Thread.currentThread());
29        // getTick()方法同步
30        if (tick.getTick()) {
31            tv.setV((tv.getV() == null ? 0 : tv.getV()) + 1);
32            tl.set(tv);
33            TimeUnit.MILLISECONDS.sleep(1);
34        } else {
35            if (!tick.isTickSupply) break;
36        }
37    }
38    return tl.get();
39}

上述代码的作用是一样的,可以看到,在资源上使用同步比在任务上使用同步的代码更加易读,简洁。

正如之前所说的,在资源上使用同步还可以避免新建任务时又重新设计同步逻辑。

因此,在资源上使用同步是建议的方式。

扩展阅读: https://docs.oracle.com/javase/tutorial/essential/concurrency/syncmeth.html



  1. Java核心技术卷1 第14章并发第14.5.6节同步阻塞 ↩︎