并发编程 synchronized

本文将介绍 Java 中的 synchronized。

一、synchronized

1. 说明

Java 提供了 synchronized 关键字,它可以用于修饰方法和代码块,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class X {

// 修饰非静态方法,被保护资源为当前对象
synchronized void foo() {
// 临界区
}

// 修饰静态方法,被保护资源为Class
synchronized static void bar() {
// 临界区
}


Object obj = new Object();

// 修饰代码块
void fun() {
// 被保护资源为obj
synchronized(obj) {
// 临界区
}
}
}
  • synchronized 会隐式地在前后自动加锁和解锁
  • 被保护资源:
    • 当 synchronized 修饰代码块时,需要显式地在括号中填入内容,该内容便是被保护资源
    • 当 synchronized 修饰非静态方法时,被保护资源是当前对象
    • 当 synchronized 修饰静态方法时,被保护资源是当前 Class
  • 当 synchronized 锁住某个方法或代码块时,同一时刻仅有一方(资源的锁的持有者)能访问临界区
  • 不被 synchronized 修饰的方法或代码块依然可以正常执行,正常访问资源

2. 应用

(1) 无锁情况

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
26
27
28
29
30
31
32
33
34
public class Test {

static class A {
int num = 0;

public int getNum() {
return num;
}

public void Add() {
num += 1;
}
}

public static void main(String[] args) throws InterruptedException {
A a = new A();
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
a.Add();
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
a.Add();
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(a.getNum());
}

}

(2) 对写操作加锁

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
26
27
28
29
30
31
32
33
34
public class Test {

static class A {
int num = 0;

public int getNum() {
return num;
}

public synchronized void Add() {
num += 1;
}
}

public static void main(String[] args) throws InterruptedException {
A a = new A();
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
a.Add();
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
a.Add();
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(a.getNum());
}

}

(3) 对读写操作加锁

如果希望对 num 进行彻底的保护,getNum() 方法也应该加上 synchronized,这是为了读取 num 值时能够读取到正确的值。

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
26
27
28
29
30
31
32
33
34
public class Test {

static class A {
int num = 0;

public synchronized int getNum() {
return num;
}

public synchronized void Add() {
num += 1;
}
}

public static void main(String[] args) throws InterruptedException {
A a = new A();
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
a.Add();
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
a.Add();
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(a.getNum());
}

}

二、被保护资源的选择

  • 被保护资源应该是私有的

    应该将被保护资源设为私有,仅允许通过 “经过了并发控制” 的指定方法间接访问

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

    synchronized long get(){
    return value;
    }

    synchronized long addOne(){
    return ++value;
    }
    }
  • 被保护资源应该是不可变的

    对于下面这段代码来说,每次执行 ++value 时,实际上执行的是 value = new Long(value + 1),被保护资源发生改变,因此是错误的。

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

    synchronized Long get(){
    synchronized (value) {
    return value;
    }
    }

    synchronized Long addOne(){
    synchronized (value) {
    return ++value;
    }
    }
    }
  • 被保护资源应该是不可重用的

    Java 的 Integer、Short、Long、Boolean、Byte、Character 运用了 “享元模式”,Java 会缓存对象以避免每次都重新实例化,我们称这些对象是可重用的。

    当被保护资源可重用时,它可以在其它代码被获取并访问。由于锁对应资源,其它代码还可以访问同一把锁,这将影响原有代码的并发控制。

三、wait()、notify()、notifyAll()

1. 调用前提

wait()notify()notifyAll() 的调用前提是已经获取到锁,即这三个方法只能 在被 synchronized 修饰的方法和代码块中 调用。

2. 调用者

wait()notify()notifyAll() 的调用者是锁对应的资源。

被 synchronized 修饰的方法和代码块中的 this 即是锁对应的资源,因此可以省略调用者,但是不允许使用其它调用者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void test(String[] args) {
Object a = new Object();
Object b = new Object();
synchronized (a) {
// 调用wait()
a.wait();

// 省略调用者调用wait()
wait();

// 错误代码
b.wait();
}
}

3. wait()

  • wait() 的作用是使当前线程释放锁,进入等待状态

  • 正在 wait 的线程将一直等待,直至以下任意一种情况的发生:

    • 有其它线程调用 notify() 方法,且该线程刚好被选中
    • 有其它线程调用 notifyAll() 方法
    • 被其它线程中断
    • 设定了超时时间且超过超时时间
  • 线程被唤醒后,它将尝试获取锁,若锁已被占据,会和与其它线程一同进入阻塞状态

  • 如果线程获取到锁,它会从 wait() 继续向下执行

  • 由于线程可能因被中断、超时而被唤醒(这通常被称为 “虚假唤醒”),此时的被唤醒是没有意义的,理应继续等待下去。因此,程序应该循环检查条件,条件不满足时 wait,通常我们使用 while ... wait 的方式,如下:

    1
    2
    3
    4
    5
    synchronized (obj) {
    while (<condition does not hold>)
    obj.wait(timeout);
    ... // Perform action appropriate to condition
    }

    A thread can also wake up without being notified, interrupted, or timing out, a so-called spurious wakeup. While this will rarely occur in practice, applications must guard against it by testing for the condition that should have caused the thread to be awakened, and continuing to wait if the condition is not satisfied. In other words, waits should always occur in loops, like this one:

      synchronized (obj) {
          while (condition does not hold)
              obj.wait(timeout);
          ... // Perform action appropriate to condition
      }

4. notify() 和 notifyAll()

  • notify() 的作用是随机唤醒一个正在 wait 的线程;

    notifyAll() 的作用是唤醒所有正在 wait 的线程

  • notify()notifyAll() 并不会让调用线程丢失锁的拥有权,它仍可以持有锁继续执行

  • 被唤醒的线程在获取锁时并没有优先权

  • 尽量使用 notifyAll()

    假如 notify() 随机唤醒了某一个线程,此线程判断发现不符合条件,继续 wait,等同于什么都没唤醒

四、中断

线程有 interrupt() 方法,用于中断。如果某个线程对象的 interrupt() 方法被调用,线程(在大多数情况下)便会被中断。

  • 线程处于等待时:

    假如线程调用了 wait()join()sleep() 等方法进入等待状态,此时调用该线程的 interrupt() 方法,线程将会结束等待状态,并且收到 InterruptedException 异常,继续向下执行。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public static void main(String[] args) {
    Thread thread = new Thread(() -> {
    synchronized (Test.class) {
    try {
    Thread.sleep(1000);
    } catch (InterruptedException e) {
    System.out.println("Thread A interrupted");
    }
    System.out.println("1111");
    }
    }, "A");
    thread.start();
    thread.interrupt();
    }

  • 线程等待锁阻塞时:

    假如线程需要的锁已被占据时,线程进入阻塞状态,此时调用该线程的 interrupt() 方法将没有作用,线程仍会继续保持阻塞状态

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public static void main(String[] args) {
    Thread thread = new Thread(() -> {
    synchronized (Test.class) {
    System.out.println("子线程获取锁");
    }
    });
    synchronized (Test.class) {
    thread.start();
    thread.interrupt();
    Thread.sleep(1000);
    System.out.println("线程结束");
    }
    }

五、synchronized 的升级

1. 升级过程

在 Java 1.6 之前,synchronized 都是重量级锁,这导致其效率低下。

Java 1.6 对 synchronized 进行了优化,引入了偏向锁和轻量级锁。

2. 偏向锁

大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争。偏向锁就是针对这一情况,其目标是在同一时刻只有一个线程获取锁时提升性能。

具体来说,线程获取锁时:

  • 首先读取资源对象头中记录的 “偏向 ThreadID”,
    • 如果等于当前线程,直接继续执行代码
    • 如果不等于当前线程,判断 “偏向 ThreadID” 对应的线程是否存活,
      • 如果存活,代表锁被多个线程竞争,升级为轻量级锁
      • 如果不存活,修改 “偏向 ThreadID” 为自身

3. 轻量级锁

轻量级锁采用 CAS 修改 "偏向 ThreadID" + 自旋 的方式不断尝试获取锁,从而避免阻塞提高性能。

如果一定次数后仍未成功获取到锁,会升级至重量级锁。

4. 重量级锁

重量级锁依赖操作系统实现,具体来说:同一时刻只有一个线程能够获取到锁,当线程尝试加锁且发现锁已被占有时,会进入等待队列并阻塞,等待唤醒后继续尝试获取锁。

在重量级锁加解锁的过程中,阻塞和唤醒线程需要切换 CPU 状态至内核模式来完成,需要耗费较多性能。如果同步代码块中的内容较为简单,状态转换消耗的时间可能比用户代码执行的时间还要长。

参考

  • Java 并发编程实战