写点什么

线程协同的利器:synchronized 锁

作者:BigBang!
  • 2023-12-05
    广东
  • 本文字数:4763 字

    阅读完需:约 16 分钟

线程协同的利器:synchronized锁

首先明确一下只有多个线程需要协同时,才可能需要用到锁。如果多个线程完全独立并行,加锁反而是画蛇添足的操作。如果我们需要控制一段代码同时只能被一个线程执行,那么最简单和便利的方式就是使用 Java 语言提供的 synchronized 关键字。sychronized 关键字非常好用,但如果不了解其背后的原理非常容易写出难以发现的 Bug。


先看一段简单的使用 synchronized 进行线程同步的代码示例:

package demo;
public class Counter { private int counter=0;
synchronized public void addOne() { this.counter ++; }
synchronized public int get() { return this.counter; }
public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); Thread[] threads = new Thread[10]; for (int i = 0; i < 10; i++) { Thread t = new Thread(() -> { for (int j = 0; j < 100; j++) { counter.addOne(); if (j % 10 == 0) { try { Thread.sleep(10); } catch (InterruptedException e) { throw new RuntimeException(e); } } } }); threads[i] = t; }
for (Thread t:threads) { t.start(); }
for (Thread t:threads) { t.join(); }
System.out.println("Counter value: " + counter.get()); }}
复制代码


这段代码的 addOne 方法,如果不加锁,在多个线程执行时,执行的结果无法保证得到 1000(你会大概率看到一个接近 1000 的数字),因为多个线程会相互覆盖相加后的结果。注意的是,Counter 的 get()方法也需要加上 synchronized 关键字。虽然在上面这个简单例子中加不加都一样,但如果 get()方法不加,你可以想像一下你在计算中途试图调用 get()方法获取 counter 值,会得到什么结果?

synchronized 工作原理

synchronized 是一个关键字,它可以加在方法或者代码块上。Java 编译器在编译被 synchronized 装饰的代码时,会自动在代码前后插入加锁和释放锁的代码,以确保程序不会出现忘记释放锁的情况(和垃圾回收一样那么贴心!),但这种编译时加锁和释放锁的机制在带来便利性的同时,也牺牲了灵活性,你无法手动的控制锁,也无法实现可重入锁、读写锁等更加高级的锁特性。

临界区和受保护的资源

被 synchronized 修饰的方法或者代码块被称为:临界区。什么样的代码需要被加锁?一定是因为这段代码要操作某些对象,而这个对象被多个线程所共享。例如:给下面这段代码加锁没有任何意义,因为这段代码没有操作任何共享的对象,加锁反而多此一举。

synchronized public void dummy() {  System.out.println("hello wolrd!");}  
复制代码


在临界区中被程序操作的对象,称为受保护资源。加锁是因为要操作共享的资源,这是理解锁和用好锁的要点。那问题来了:锁在保护什么共享资源?如何保护共享资源?

sychronzied 锁住了什么?


  1. 如果 synchronized 加在一个对象方法上,那么锁住的是方法所在的对象实例:this;

  2. 如果 synchronized 加在一个类方法上,那么锁住的是类的 Class 对象;

  3. 如果 synchronized 加在一个代码块上,那么锁住的是传给 sychronized 的对象;


当使用 synchronized 锁住代码块时,大部分人习惯使用的方式是传入 this 对象,这非常方便,也和把 synchronized 加在对象方法上保持一致。但很多时候,如果一个对象中的多段代码都需要加锁去锁定不同的资源,使用 this 往往不可行,那我们可以用一个自定义的 Object 对象作为锁定的对象。

    public void addOne() {        synchronized(this) {            this.counter ++;        }    }
复制代码

或者

    final private Object myLock = new Object();
public void addOne() { synchronized(myLock) { this.counter ++; } }
复制代码

注意作为锁的对象最好声明为 final,而且对象的内容必须保持不变,一般习惯上用 Object 作为锁对象。如果锁对象的内容在运行时发生了变化,那么锁将会失去效用。


还有一个点要注意的是,锁作用的对象是临界区,但一个临界区可以操作多个共享资源,因此一把锁可以锁住多个共享资源。但这里有一个很容易产生错误的点,就是某个共享资源在多个共享区中被操作,如果它没有被同一把锁给锁住,就会导致错误。


如下面代码所示,counter 对象在两个临界区中被操作,但每个临界区用的是不同的锁,导致 counter 的值没有被正确加减,如果要得到正确的结果,addOne()和 minusOne()都应该使用 sameLock 进行加锁:


package demo;
public class Counter { private int counter=0;
final private Object sameLock = new Object();
final private Object addOneLock = new Object();
final private Object minusOneLock = new Object();
public void addOne() { synchronized(addOneLock) { this.counter ++; } }
public void minusOne() { synchronized (minusOneLock) { this.counter --; } }
synchronized public int get() { return this.counter; }
public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); Thread[] addOneThreads = new Thread[10]; Thread[] minusOneThreads = new Thread[5]; for (int i = 0; i < 10; i++) { Thread t = new Thread(() -> { for (int j = 0; j < 10000; j++) { counter.addOne(); if (j % 10 == 0) { try { Thread.sleep(20); } catch (InterruptedException e) { throw new RuntimeException(e); } } } }); addOneThreads[i] = t; }
//创建5个减法线程 for (int i = 0;i<5;i++) { Thread t = new Thread(() -> { for (int n = 0; n<10000;n++) { counter.minusOne(); if (n % 10 == 0) { try { Thread.sleep(20); } catch (InterruptedException e) { throw new RuntimeException(e); } } } }); minusOneThreads[i] = t; }
for (Thread t:addOneThreads) { t.start(); }
for (Thread t: minusOneThreads) { t.start(); }
for (Thread t:addOneThreads) { t.join(); }
for (Thread t: minusOneThreads) { t.join(); }
System.out.println("Counter value: " + counter.get()); }}
复制代码

最佳实践总结:

当有多个共享资源需要加锁控制时,给每个共享资源只加一把锁,让锁和资源 1:1 对应,这样无论你在哪个临界区去操作这个变量,都能实现正确的控制。

线程等待和通知机制

让我们先回忆一下排队买火车票的场景。一个售票窗口(临界区)同时只能服务一个人,其他人只能在后面排队等待。如果一个人来到了窗口,但发现自己要买的车次暂时没票了,那么这个人需要退到一边,让下一个人买票。等到这个车次有票了,售票员大喊一声:某某车次有票了。于是所有要购买这个车次的人都冲向售票窗口,但他们也需要重新排队,而且有可能某个人排到了窗口,发现又没票了,于是只好继续等待。


Java 线程的等待和通知机制基本上和上述生活中的场景一致。当我们使用 sychronized 给一段代码加锁时,如果一个线程先获得了锁且还没有释放,那么其它线程试图执行这段加锁的代码,会进入一个阻塞队列排队等待锁。Java 会给每一把锁创建一个阻塞线程队列,等到当前执行线程释放锁后,Java 从这个队列中按先来后到的顺序选择下一个线程执行。


同时获得锁的线程在执行过程中发现条件不满足,可以主动进入等待状态,同时释放锁让其它线程执行,等到条件满足后再被通知执行。在 Java 中实现这样的功能要使用到一对方法:

  1. 某个线程主动进入等待:lock.wait()

  2. 某个线程通知其它线程:lock.notifyAll()、lock.notify()


所有调用 lock.wait()方法的线程会进入到这个 lock 的另外一个阻塞队列中(注意:和等待 synchronized 锁的阻塞队列不是同一个)。当 lock.notifyAll()方法被调用,所有 wait 状态的线程会被唤醒,然后尝试获得锁并执行。注意当一个线程获得锁,有可能条件又不满足了,因此一般我们要把 wait()调用放到 while 循环中:

while(!condition) {   lock.wait(); }
复制代码

如果没有特殊的理由,不要使用 notify()。notify()方法只会随机选择一个线程来执行,万一这个线程条件不满足继续等待,而其它线程又没有被唤醒,会导致某些线程永远不被执行的情况。


下面是一个简单的示例,这里我特意创建了一个 lock,然后调用 lock.wait(), 而不是大家常用的 this.wait()。

package demo;
public class WaitNotifyDemo { private int box=0; private Object lock = new Object();
public void putMoney(int value) { synchronized (lock) { box = value; lock.notifyAll(); } }
public int getMoney() throws InterruptedException { int result; synchronized (lock) { while (box == 0) { lock.wait(); } result = box; box = 0; return result; } }
public static void main(String[] args) { WaitNotifyDemo demo = new WaitNotifyDemo();
Thread someOne = new Thread(() -> { for (int i = 0; i < 10; i++) { demo.putMoney(i); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } } demo.putMoney(-1); }); someOne.start();
Thread picker = new Thread(() -> { while (true) { try { int money = demo.getMoney(); if (money < 0) { System.out.println("no more money!"); break; } System.out.println("pick money: " + money); Thread.sleep(100); } catch (InterruptedException e) { throw new RuntimeException(e); } } });
picker.start(); }}
复制代码

注意:在上述代码中,我们调用了 Thread.sleep()来模拟时间间隔,而 sleep()方法并不会释放锁,即使这个线程处于 sleep 状态,其它线程也依然在等待。因此在实际生产场景中,我们一般要避免在锁代码块中使用 sleep。

发布于: 刚刚阅读数: 5
用户头像

BigBang!

关注

宁静致远,快乐随行,知行合一,得大自在! 2008-10-08 加入

一个程序员,一名架构师,一位技术管理人......

评论

发布
暂无评论
线程协同的利器:synchronized锁_Java多线程_BigBang!_InfoQ写作社区