多个线程同时访问共享状态时,容易出现线程干扰和内存可见性问题。synchronized 和 volatile 都可以处理并发问题,但作用并不相同。下面通过几个小例子整理它们的基本用法。

synchronized

synchronized 用于控制线程同步,可以加在代码块、实例方法或静态方法上。

先看一个没有同步的例子:

public class SynchronizedDemo {
    private int count = 10;

    public void decrement() {
        count--;
        System.out.println(Thread.currentThread().getName() + " -> " + count);
    }

    public static void main(String[] args) {
        SynchronizedDemo demo = new SynchronizedDemo();
        for (int i = 0; i < 10; i++) {
            new Thread(demo::decrement, "thread-" + i).start();
        }
    }
}

count-- 包含读取、减一和写回几个步骤,不是一个原子操作。多个线程可能同时读到同一个值,因此输出顺序和最终结果都不确定,某些数字也可能重复。

synchronized 的几种写法

使用一个专门的对象作为锁。线程进入代码块前必须先取得 lock 的监视器锁:

public class SynchronizedDemo {
    private int count = 10;
    private final Object lock = new Object();

    public void decrement() {
        synchronized (lock) {
            count--;
            System.out.println(count);
        }
    }
}

如果要保护的是当前对象的状态,也可以锁住 this:

public void decrement() {
    synchronized (this) {
        count--;
        System.out.println(count);
    }
}

当整个方法都需要使用当前对象的锁时,可以直接把方法声明为 synchronized,它与上面的写法作用相同:

public synchronized void decrement() {
    count--;
    System.out.println(count);
}

静态方法不属于某个实例。静态同步方法锁住的是当前类对应的 Class 对象:

public class SynchronizedDemo {
    private static int count = 10;

    public static synchronized void decrement() {
        count--;
        System.out.println(count);
    }
}

等价于:

public static void decrement() {
    synchronized (SynchronizedDemo.class) {
        count--;
        System.out.println(count);
    }
}

实例对象的锁和类对象的锁不是同一把锁,不能互相阻塞。

读取中间状态

如果写方法加锁,读方法却不使用同一把锁,读线程可能看到更新到一半的状态:

import java.util.concurrent.TimeUnit;

public class ReadStateDemo {
    private String name;
    private int score;

    public synchronized void set(String name, int score) {
        this.name = name;
        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return;
        }
        this.score = score;
    }

    public String get() {
        return name + " -> " + score;
    }

    public static void main(String[] args) throws InterruptedException {
        ReadStateDemo demo = new ReadStateDemo();
        new Thread(() -> demo.set("zhangsan", 98)).start();

        TimeUnit.SECONDS.sleep(1);
        System.out.println(demo.get());

        TimeUnit.SECONDS.sleep(2);
        System.out.println(demo.get());
    }
}

一种可能的结果是:

zhangsan -> 0
zhangsan -> 98

第一次读取发生在两个字段的写入之间,所以拿到了中间状态。由于这里本身存在数据竞争,实际结果不能依赖线程休眠来保证。

把 get() 也声明为 synchronized 后,读写使用同一个对象锁。读线程会等 set() 完成,再在锁内取得两个字段组成的结果:

public synchronized String get() {
    return name + " -> " + score;
}

如果一组字段必须保持一致,就要让相关读写都遵守同一个同步规则。

模拟死锁

两个线程以相反顺序取得两把锁时,可能互相等待:

import java.util.concurrent.TimeUnit;

public class DeadlockDemo {
    private final Object lock1 = new Object();
    private final Object lock2 = new Object();

    public void method1() {
        synchronized (lock1) {
            System.out.println("method1 获得 lock1");
            sleep(2);
            synchronized (lock2) {
                System.out.println("method1 获得 lock2");
            }
        }
    }

    public void method2() {
        synchronized (lock2) {
            System.out.println("method2 获得 lock2");
            synchronized (lock1) {
                System.out.println("method2 获得 lock1");
            }
        }
    }

    private static void sleep(int seconds) {
        try {
            TimeUnit.SECONDS.sleep(seconds);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        DeadlockDemo demo = new DeadlockDemo();
        new Thread(demo::method1, "thread-1").start();
        TimeUnit.SECONDS.sleep(1);
        new Thread(demo::method2, "thread-2").start();
    }
}

按照这个调度顺序:

  1. thread-1 进入 method1(),取得 lock1。
  2. thread-2 进入 method2(),取得 lock2,然后等待 lock1。
  3. thread-1 结束休眠,开始等待 lock2。
  4. 两个线程都在等待对方持有的锁,程序无法继续。

避免死锁最直接的办法,是让所有代码都按相同顺序获取多把锁,并尽量缩小持锁范围。

synchronized 是可重入的

线程已经持有某个对象的锁时,可以再次取得同一把锁。因此同步方法可以调用同一对象上的另一个同步方法:

public class ReentrantDemo {
    public synchronized void method1() {
        System.out.println("method1");
        method2();
    }

    public synchronized void method2() {
        System.out.println("method2");
    }

    public static void main(String[] args) {
        ReentrantDemo demo = new ReentrantDemo();
        new Thread(demo::method1).start();
    }
}

如果内置锁不可重入,method1() 调用 method2() 时就会把自己阻塞住。

异常也会释放锁

同步方法或同步代码块因为未捕获异常而退出时,JVM 仍然会释放监视器锁:

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

public class ExceptionDemo {
    private final CountDownLatch firstThreadEntered = new CountDownLatch(1);

    public synchronized void execute() {
        String name = Thread.currentThread().getName();
        System.out.println(name + " 获得锁");

        if ("thread-1".equals(name)) {
            firstThreadEntered.countDown();
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
            throw new IllegalStateException("模拟异常");
        }

        System.out.println(name + " 继续执行");
    }

    public void awaitFirstThread() throws InterruptedException {
        firstThreadEntered.await();
    }

    public static void main(String[] args) throws InterruptedException {
        ExceptionDemo demo = new ExceptionDemo();
        new Thread(demo::execute, "thread-1").start();
        demo.awaitFirstThread();
        new Thread(demo::execute, "thread-2").start();
    }
}

thread-1 抛出异常后会退出同步方法并释放锁,等待中的 thread-2 随后可以进入。锁虽然释放了,共享状态却可能只更新了一部分,所以仍然需要处理异常带来的数据一致性问题。

volatile

保证可见性

下面的代码中,主线程把 flag 改成 true,工作线程却不保证能够观察到这个变化:

import java.util.concurrent.TimeUnit;

public class VolatileDemo {
    private boolean flag = false;

    public void run() {
        System.out.println("run start");
        while (!flag) {
            // 等待状态变化
        }
        System.out.println("flag changed");
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileDemo demo = new VolatileDemo();
        new Thread(demo::run, "worker").start();

        TimeUnit.SECONDS.sleep(1);
        demo.flag = true;
    }
}

因为 flag 存在没有同步的数据竞争,工作线程可能一直使用自己已经读到的值,程序不保证退出。把字段声明为 volatile:

private volatile boolean flag = false;

对 volatile 字段的写入 happens-before 后续线程对同一字段的读取。工作线程再次读取 flag 时能够看到新的值,并退出循环:

run start
flag changed

volatile 还会建立内存顺序:一个线程在写入 volatile 字段之前完成的普通写入,对随后读到该 volatile 值的线程也可见。

不保证复合操作的原子性

volatile 可以保证单次读写的可见性,但 count++ 仍然是读取、计算和写回组成的复合操作:

public class VolatileCounterDemo {
    private volatile int count = 0;

    public void increment() {
        for (int i = 0; i < 10000; i++) {
            count++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileCounterDemo demo = new VolatileCounterDemo();
        Thread t1 = new Thread(demo::increment);
        Thread t2 = new Thread(demo::increment);

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(demo.count);
    }
}

两个线程都可能读到相同的旧值,分别加一后又写回同一个结果,造成更新丢失,所以最终值可能小于 20000。

这个计数可以使用同步代码块,也可以改用 AtomicInteger:

import java.util.concurrent.atomic.AtomicInteger;

private final AtomicInteger count = new AtomicInteger();

public void increment() {
    for (int i = 0; i < 10000; i++) {
        count.incrementAndGet();
    }
}

synchronized 同时提供互斥和可见性,适合保护一组需要整体保持一致的操作;volatile 主要解决状态的可见性和顺序问题,适合像停止标记这样不依赖复合更新的变量。

最后修改:2017 年 12 月 09 日
如果觉得我的文章对你有用,请随意赞赏