多个线程同时访问共享状态时,容易出现线程干扰和内存可见性问题。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();
}
}按照这个调度顺序:
thread-1进入method1(),取得lock1。thread-2进入method2(),取得lock2,然后等待lock1。thread-1结束休眠,开始等待lock2。- 两个线程都在等待对方持有的锁,程序无法继续。
避免死锁最直接的办法,是让所有代码都按相同顺序获取多把锁,并尽量缩小持锁范围。
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 changedvolatile 还会建立内存顺序:一个线程在写入 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 主要解决状态的可见性和顺序问题,适合像停止标记这样不依赖复合更新的变量。