首先明确一下只有多个线程需要协同时,才可能需要用到锁。如果多个线程完全独立并行,加锁反而是画蛇添足的操作。如果我们需要控制一段代码同时只能被一个线程执行,那么最简单和便利的方式就是使用 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 锁住了什么?
如果 synchronized 加在一个对象方法上,那么锁住的是方法所在的对象实例:this;
如果 synchronized 加在一个类方法上,那么锁住的是类的 Class 对象;
如果 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 中实现这样的功能要使用到一对方法:
某个线程主动进入等待:lock.wait()
某个线程通知其它线程: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。
评论