02 synchronized

synchronized 关键字采用对代码块/方法体加锁的方式解决 Java 中多线程访问同一个资源时,引起的资源冲突问题。

一句话总结:synchronized 能够保证同一时刻最多只有一个线程执行某段代码,以达到保证并发安全的效果。

使用方式

synchronized 同步锁分对象锁和类锁:

  • 对象锁:对单个实例对象的独享内存的部分区域加锁

  • 类锁:对整个类的共享内存的部分区域加锁

synchronized 关键字最主要的三种使用方式:

  1. 修饰实例方法

  2. 修饰静态方法

  3. 修饰代码块

对象锁,修饰代码块/实例方法

public class T {
private int count = 10;
private Object o = new Object();
private void m1() {
// 任何线程需要执行下面代码,需要拿到 o 的锁
synchronized (o) {
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
}
private void m2() {
// 使用 this 对象,而不需要手动创建 o 对象
// 任何线程需要执行下面代码,需要拿到 this 的锁
synchronized (this) {
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
}
// 等同于 synchronized (this)
private synchronized void m3() {
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
}

类锁,修饰代码块/静态方法

public class T {
private static int count = 10;
public static void m1() {
synchronized (T.class) {
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
}
// 等同于 包名.T.class
private synchronized static void m2() {
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
}

synchronized 是原子操作,原子操作不可分。

一些栗子

0x01 丢失的请求数

public class T implements Runnable {
private int count = 10;
@Override
public /* synchronized */ void run() {
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
public static void main(String[] args) {
T t = new T();
for (int i = 0; i < 5; i++) {
new Thread(t, "Thread" + i).start();
}
}
}

0x02 脏读

业务写方法加锁,读方法不加锁,产生脏读问题:

public class Account {
private String name;
private double balance;
public synchronized void set(String name, double balance) {
this.name = name;
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.balance = balance;
}
// 读方法也加锁后,脏读不出现
private /* synchronized */ double get(String name) {
return this.balance;
}
public static void main(String[] args) {
Account a = new Account();
new Thread(() -> a.set("shiyu", 100)).start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("balance: " + a.get("shiyu"));
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("balance: " + a.get("shiyu"));
}
}
balance: 0.0
balance: 100.0

0x03 同步与非同步方法同时调用

public class T {
public synchronized void m1() {
System.out.println(Thread.currentThread().getName() + " m1 start");
try {
Thread.sleep(6000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " m1 end");
}
public void m2() {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " m2 end");
}
public static void main(String[] args) {
T t = new T();
new Thread(t::m1, "t1").start();
new Thread(t::m2, "t2").start();
}
}
t1 m1 start
t2 m2 end
t1 m1 end

0x04 同步方法调用

一个同步方法可以调用另一个同步方法,一个线程已经拥有了某个对象的锁,再次申请的时候仍然会得到该对象的锁,即 synchronied 同步锁可重入,可重入的粒度是线程级。

可重入的好处:

  1. 避免死锁

  2. 提升封装性,避免重复的加锁和释放锁

synchronied 的两大特性:可重入和不可中断。

public class T {
public synchronized void m1() {
System.out.println("m1 start");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
m2();
}
public synchronized void m2() {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("m2 end");
}
}

0x05 子类调用父类同步锁

下面栗子中子类和父类的锁对象是同一个,是 new TT() 子类对象,因为 synchronized 修饰的方法锁的是 this 对象,而这里 this 就是指向子类对象。

public class T {
public synchronized void m() {
System.out.println("m start");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("m end");
}
public static void main(String[] args) {
new TT().m();
}
}
class TT extends T {
@Override
public synchronized void m() {
System.out.println("child m start");
super.m();
System.out.println("child m end");
}
}
child m start
m start
m end
child m end

0x06 异常释放锁

程序在执行过程中,如果出现异常,默认情况下锁会被释放。所以,在并发处理过程中,有异常要多加小心,不然可能会发生不一致的情况。

比如第一个线程出现异常,锁被释放,其他线程进入同步代码区,有可能会访问到异常产生时的数据。

public class T {
private int count = 0;
public synchronized void m() {
System.out.println(Thread.currentThread().getName() + " start");
while (true) {
count++;
System.out.println(Thread.currentThread().getName() + " count = " + count);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 此处抛出异常,锁将被释放,如果不想释放锁,需要进行异常捕获
if (count == 5) {
int i = 1 / 0;
}
}
}
public static void main(String[] args) {
T t = new T();
new Thread(t::m, "t1").start();
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 理论上 t2 线程永远抢不到锁,因为 t1 线程一直在执行,但是由于 t1 线程抛出异常,锁被释放,所以 t2 线程能够执行
new Thread(t::m, "t2").start();
}
}
t1 start
t1 count = 1
t1 count = 2
t1 count = 3
t1 count = 4
t1 count = 5
Exception in thread "t1" t2 start
t2 count = 6
java.lang.ArithmeticException: / by zero
at s007.T.m(T.java:27)
at java.lang.Thread.run(Thread.java:748)
t2 count = 7
t2 count = 8
t2 count = 9

语句优化

synchronized 同步代码块中的语句越少越好。比较下面 m1 和 m2。

public class T {
private int count = 0;
public synchronized void m1() {
// do sth need not sync
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 业务逻辑中只有下面这句需要 sync,这时不应该给整个方法上锁
count++;
}
public void m2() {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 采用细粒度的锁,可以使线程争用时间变短,从而提高效率
synchronized (this) {
count++;
}
}
}

锁对象改变

锁定某对象 o,如果 o 属性发生变化,不影响锁的使用。但是如果 o 引用了新的一个对象,则锁定的对象发生改变。应该避免将锁定对象的引用变成另外的对象。

public class T {
private Object o = new Object();
public void m() {
synchronized (o) {
while (true) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName());
}
}
}
public static void main(String[] args) {
T t = new T();
// 启动第一个线程
new Thread(t::m, "t1").start();
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 创建第二个线程
new Thread(t::m, "t2").start();
// 锁对象发生改变,所以 t2 线程得以启动,如果注释这句,t2 线程永远无法启动
t.o = new Object();
}
}

避免字符串作为锁对象

不要以字符串常量作为锁对象,在下面栗子中,m1 和 m2 其实锁定的是同一个对象。

public class T {
private String s1 = "Hello";
private String s2 = "Hello";
public void m1() {
synchronized (s1) {
System.out.println("m1 start");
while (true) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public void m2() {
synchronized (s2) {
System.out.println("m2 start");
}
}
public static void main(String[] args) {
T t = new T();
// 只有 t1 启动,t2 无法启动
new Thread(t::m1, "t1").start();
new Thread(t::m2, "t2").start();
}
}

面试题

0x01 元素监听

实现一个容器,提供两个方法 add 和 size。写两个线程,线程 1 添加 10 个元素到容器中,线程 2 实现监控元素的个数,当个数到 5 时,线程 2 给出提示并结束。

初步实现:

public class T {
// 添加 volatile,使 t2 能够得到通知
private volatile List<Object> list = new ArrayList<>();
public void add(Object o) {
list.add(o);
}
private int size() {
return list.size();
}
public static void main(String[] args) {
T t = new T();
new Thread(() -> {
for (int i = 0; i < 10; i++) {
t.add(new Object());
System.out.println("add " + i);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
new Thread(() -> {
// 虽然可以实现,但是浪费 cpu,且不够精确
while (true) {
if(t.size() == 5) {
break;
}
}
System.out.println("t2 end");
}).start();
}
}

上面方法有两个缺点,一是循环判断浪费 cpu,二是判断时依旧可能会新增元素,不够精确。

进阶优化版:

public class T {
private volatile List<Object> list = new ArrayList<>();
public void add(Object o) {
list.add(o);
}
private int size() {
return list.size();
}
public static void main(String[] args) {
T t = new T();
final Object LOCK = new Object();
new Thread(() -> {
synchronized (LOCK) {
System.out.println("t2 start");
if (t.size() != 5) {
try {
LOCK.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("t2 end");
// 让 t2 继续执行
LOCK.notify();
}
}, "t2").start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(() -> {
synchronized (LOCK) {
System.out.println("t1 start");
for (int i = 0; i < 10; i++) {
t.add(new Object());
System.out.println("add " + i);
if (t.size() == 5) {
LOCK.notify();
// 释放锁,让 t1 继续执行
try {
LOCK.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}, "t1").start();
}
}

需要注意的是 wait 会释放锁,而 notify 不会释放锁。需要让 t2 监听线程先执行,然后等待,t1 线程添加元素到 5 个时通知 t2,同时 t1 自己 wait 释放锁,不然 t2 无法执行,等到 t2 执行完毕,再通知 t1 执行。t1、t2 线程交替执行,通信过程比较繁琐。

最终优化版,使用门闩 CountDownLatch 代替 waitnotifyCountDownLatch 不涉及到锁定:

public class T {
private volatile List<Object> list = new ArrayList<>();
public void add(Object o) {
list.add(o);
}
private int size() {
return list.size();
}
public static void main(String[] args) {
T t = new T();
CountDownLatch latch = new CountDownLatch(1);
new Thread(() -> {
System.out.println("t2 start");
if (t.size() != 5) {
try {
latch.await();
// 也可以指定时间
// latch.await(1000,TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("t2 end");
}, "t2").start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(() -> {
System.out.println("t1 start");
for (int i = 0; i < 10; i++) {
t.add(new Object());
System.out.println("add " + i);
if (t.size() == 5) {
latch.countDown();
}
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "t1").start();
}
}

当不涉及同步,只是涉及线程通信的时候,用 synchorized + wait/notify 显得太重了。